@flamingo-stack/openframe-frontend-core 0.0.488 → 0.0.489-snapshot.20260728173516

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 (153) hide show
  1. package/dist/{chunk-GQX6LFFA.cjs → chunk-2MKUHJ3O.cjs} +42 -42
  2. package/dist/{chunk-GQX6LFFA.cjs.map → chunk-2MKUHJ3O.cjs.map} +1 -1
  3. package/dist/{chunk-DX6AOHNU.cjs → chunk-3IEVA5AO.cjs} +77 -77
  4. package/dist/{chunk-DX6AOHNU.cjs.map → chunk-3IEVA5AO.cjs.map} +1 -1
  5. package/dist/{chunk-PNXNWGBY.js → chunk-6CFFISUC.js} +2 -2
  6. package/dist/{chunk-F6BYUO5U.js → chunk-6CJ2K7BD.js} +3 -3
  7. package/dist/{chunk-KFQXO7QB.js → chunk-6GT557KN.js} +1 -1
  8. package/dist/chunk-6GT557KN.js.map +1 -0
  9. package/dist/{chunk-AJNDDXKC.js → chunk-6QK3ONDD.js} +3 -3
  10. package/dist/{chunk-H7JX2BG7.cjs → chunk-7I42V2OD.cjs} +75 -75
  11. package/dist/{chunk-H7JX2BG7.cjs.map → chunk-7I42V2OD.cjs.map} +1 -1
  12. package/dist/{chunk-AHDJMEEN.js → chunk-AIRNG27G.js} +7 -7
  13. package/dist/{chunk-RZSA6MZL.cjs → chunk-AW63ET2A.cjs} +15 -15
  14. package/dist/{chunk-RZSA6MZL.cjs.map → chunk-AW63ET2A.cjs.map} +1 -1
  15. package/dist/{chunk-A7IPON2V.cjs → chunk-BIKOJ4FH.cjs} +5 -5
  16. package/dist/{chunk-A7IPON2V.cjs.map → chunk-BIKOJ4FH.cjs.map} +1 -1
  17. package/dist/{chunk-S52LJNMU.js → chunk-BZMOEWWS.js} +7 -7
  18. package/dist/{chunk-SIKCXKGB.cjs → chunk-D5LQMRPU.cjs} +7 -7
  19. package/dist/{chunk-SIKCXKGB.cjs.map → chunk-D5LQMRPU.cjs.map} +1 -1
  20. package/dist/{chunk-HQYJINFV.js → chunk-EGXCWZYP.js} +4 -4
  21. package/dist/{chunk-EUYIFALO.cjs → chunk-EJQUYZXF.cjs} +14 -14
  22. package/dist/{chunk-EUYIFALO.cjs.map → chunk-EJQUYZXF.cjs.map} +1 -1
  23. package/dist/{chunk-V3CNNMY3.cjs → chunk-FY5XAW3H.cjs} +18 -18
  24. package/dist/{chunk-V3CNNMY3.cjs.map → chunk-FY5XAW3H.cjs.map} +1 -1
  25. package/dist/{chunk-FOQV6RCO.cjs → chunk-GMPYR3MX.cjs} +29 -29
  26. package/dist/{chunk-FOQV6RCO.cjs.map → chunk-GMPYR3MX.cjs.map} +1 -1
  27. package/dist/{chunk-EYEIVYYF.cjs → chunk-GUTYUMGV.cjs} +7 -7
  28. package/dist/{chunk-EYEIVYYF.cjs.map → chunk-GUTYUMGV.cjs.map} +1 -1
  29. package/dist/{chunk-FZOBQG6M.cjs → chunk-IEZ3YBAP.cjs} +35 -35
  30. package/dist/{chunk-FZOBQG6M.cjs.map → chunk-IEZ3YBAP.cjs.map} +1 -1
  31. package/dist/{chunk-PECGZWQR.cjs → chunk-KP5KRRD6.cjs} +3 -3
  32. package/dist/{chunk-PECGZWQR.cjs.map → chunk-KP5KRRD6.cjs.map} +1 -1
  33. package/dist/{chunk-2B6773QM.cjs → chunk-LILQJ3UZ.cjs} +1 -1
  34. package/dist/chunk-LILQJ3UZ.cjs.map +1 -0
  35. package/dist/{chunk-SCCTJ2UI.js → chunk-MPYDX5WX.js} +4 -4
  36. package/dist/{chunk-YJSKQ2ZV.js → chunk-NGEFYPY7.js} +3 -3
  37. package/dist/{chunk-77DFPD35.js → chunk-OQIMH2WQ.js} +5515 -5377
  38. package/dist/chunk-OQIMH2WQ.js.map +1 -0
  39. package/dist/{chunk-KLYHZRAD.js → chunk-PB3BTZDU.js} +2 -2
  40. package/dist/{chunk-CWQACI4Z.js → chunk-RW2LPL72.js} +2 -2
  41. package/dist/{chunk-YEEHB4T6.cjs → chunk-UZZT65PQ.cjs} +40 -40
  42. package/dist/{chunk-YEEHB4T6.cjs.map → chunk-UZZT65PQ.cjs.map} +1 -1
  43. package/dist/{chunk-LLWBPPZ4.js → chunk-VDZO6IDR.js} +6 -6
  44. package/dist/{chunk-3BFX6HJM.js → chunk-VL2YMDPH.js} +6 -6
  45. package/dist/{chunk-5RXKOGH6.cjs → chunk-VUXNXWOQ.cjs} +496 -358
  46. package/dist/chunk-VUXNXWOQ.cjs.map +1 -0
  47. package/dist/{chunk-PXF5J24T.js → chunk-WLM3F3WU.js} +2 -2
  48. package/dist/components/case-studies/index.cjs +10 -10
  49. package/dist/components/case-studies/index.js +4 -4
  50. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  51. package/dist/components/chat/guide-display.d.ts +14 -0
  52. package/dist/components/chat/guide-display.d.ts.map +1 -0
  53. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts +47 -0
  54. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts.map +1 -0
  55. package/dist/components/chat/hooks/use-chat.d.ts +1 -0
  56. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  57. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  58. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  59. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +4 -0
  60. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  61. package/dist/components/chat/index.cjs +6 -4
  62. package/dist/components/chat/index.cjs.map +1 -1
  63. package/dist/components/chat/index.d.ts +1 -0
  64. package/dist/components/chat/index.d.ts.map +1 -1
  65. package/dist/components/chat/index.js +5 -3
  66. package/dist/components/chat/types/component.types.d.ts +6 -0
  67. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  68. package/dist/components/chat/types/message.types.d.ts +15 -2
  69. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  70. package/dist/components/chat/types/processing.types.d.ts +3 -0
  71. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  72. package/dist/components/chat/types/unified-chat-state.types.d.ts +5 -0
  73. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  74. package/dist/components/chat/utils/chat-conversation-storage.d.ts +34 -0
  75. package/dist/components/chat/utils/chat-conversation-storage.d.ts.map +1 -0
  76. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  77. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
  78. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  79. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  80. package/dist/components/contact/index.cjs +5 -5
  81. package/dist/components/contact/index.js +4 -4
  82. package/dist/components/docs/index.cjs +7 -7
  83. package/dist/components/docs/index.js +6 -6
  84. package/dist/components/embeds/index.cjs +5 -5
  85. package/dist/components/embeds/index.js +4 -4
  86. package/dist/components/faq/index.cjs +6 -6
  87. package/dist/components/faq/index.js +5 -5
  88. package/dist/components/features/index.cjs +4 -4
  89. package/dist/components/features/index.js +3 -3
  90. package/dist/components/help-center-pages/index.cjs +23 -23
  91. package/dist/components/help-center-pages/index.js +14 -14
  92. package/dist/components/index.cjs +142 -140
  93. package/dist/components/index.cjs.map +1 -1
  94. package/dist/components/index.js +14 -12
  95. package/dist/components/index.js.map +1 -1
  96. package/dist/components/navigation/index.cjs +4 -4
  97. package/dist/components/navigation/index.js +3 -3
  98. package/dist/components/onboarding-guides/index.cjs +8 -8
  99. package/dist/components/onboarding-guides/index.js +7 -7
  100. package/dist/components/related-content/index.cjs +6 -6
  101. package/dist/components/related-content/index.js +5 -5
  102. package/dist/components/tickets/index.cjs +7 -7
  103. package/dist/components/tickets/index.js +6 -6
  104. package/dist/components/ui/index.cjs +6 -4
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.js +5 -3
  107. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  108. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  109. package/dist/contexts/index.cjs +2 -2
  110. package/dist/contexts/index.js +1 -1
  111. package/dist/hooks/index.cjs +3 -3
  112. package/dist/hooks/index.js +2 -2
  113. package/dist/index.cjs +6 -4
  114. package/dist/index.cjs.map +1 -1
  115. package/dist/index.js +5 -3
  116. package/dist/utils/index.cjs.map +1 -1
  117. package/dist/utils/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/components/chat/chat-message-enhanced.tsx +63 -52
  120. package/src/components/chat/guide-display.tsx +44 -0
  121. package/src/components/chat/hooks/use-chat-history-hydration.ts +142 -0
  122. package/src/components/chat/hooks/use-chat.ts +12 -0
  123. package/src/components/chat/hooks/use-nats-chat-adapter.ts +2 -0
  124. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +10 -0
  125. package/src/components/chat/hooks/use-sse-chat-adapter.ts +109 -162
  126. package/src/components/chat/index.ts +1 -0
  127. package/src/components/chat/types/component.types.ts +9 -0
  128. package/src/components/chat/types/message.types.ts +17 -1
  129. package/src/components/chat/types/processing.types.ts +1 -0
  130. package/src/components/chat/types/unified-chat-state.types.ts +6 -0
  131. package/src/components/chat/utils/chat-conversation-storage.ts +83 -0
  132. package/src/components/chat/utils/chunk-parser.ts +6 -0
  133. package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
  134. package/src/components/chat/utils/process-historical-messages.ts +6 -0
  135. package/src/contexts/chat-runtime-context.tsx +8 -0
  136. package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
  137. package/dist/chunk-2B6773QM.cjs.map +0 -1
  138. package/dist/chunk-5RXKOGH6.cjs.map +0 -1
  139. package/dist/chunk-77DFPD35.js.map +0 -1
  140. package/dist/chunk-KFQXO7QB.js.map +0 -1
  141. /package/dist/{chunk-PNXNWGBY.js.map → chunk-6CFFISUC.js.map} +0 -0
  142. /package/dist/{chunk-F6BYUO5U.js.map → chunk-6CJ2K7BD.js.map} +0 -0
  143. /package/dist/{chunk-AJNDDXKC.js.map → chunk-6QK3ONDD.js.map} +0 -0
  144. /package/dist/{chunk-AHDJMEEN.js.map → chunk-AIRNG27G.js.map} +0 -0
  145. /package/dist/{chunk-S52LJNMU.js.map → chunk-BZMOEWWS.js.map} +0 -0
  146. /package/dist/{chunk-HQYJINFV.js.map → chunk-EGXCWZYP.js.map} +0 -0
  147. /package/dist/{chunk-SCCTJ2UI.js.map → chunk-MPYDX5WX.js.map} +0 -0
  148. /package/dist/{chunk-YJSKQ2ZV.js.map → chunk-NGEFYPY7.js.map} +0 -0
  149. /package/dist/{chunk-KLYHZRAD.js.map → chunk-PB3BTZDU.js.map} +0 -0
  150. /package/dist/{chunk-CWQACI4Z.js.map → chunk-RW2LPL72.js.map} +0 -0
  151. /package/dist/{chunk-LLWBPPZ4.js.map → chunk-VDZO6IDR.js.map} +0 -0
  152. /package/dist/{chunk-3BFX6HJM.js.map → chunk-VL2YMDPH.js.map} +0 -0
  153. /package/dist/{chunk-PXF5J24T.js.map → chunk-WLM3F3WU.js.map} +0 -0
@@ -40,17 +40,21 @@
40
40
  */
41
41
 
42
42
  import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
43
- import { useChat, type Message, type StreamFnExtraOptions } from './use-chat'
43
+ import { useChat, type StreamFnExtraOptions } from './use-chat'
44
44
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
45
45
  import type { ChatRef } from '../chat-ref.types'
46
46
  import { buildChatRefKey } from '../types/chat.types'
47
47
  import type { MessageSegment } from '../types/message.types'
48
48
  import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
49
- import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
50
49
  import { chatAuthedFetch } from '../utils/chat-authed-fetch'
50
+ import {
51
+ createChatConversationStorage,
52
+ pruneStaleChatConversationStorage,
53
+ type PersistedChatConversation,
54
+ } from '../utils/chat-conversation-storage'
55
+ import type { LocalStorageAdapter } from '../../../utils/local-storage-adapter'
56
+ import { useChatHistoryHydration } from './use-chat-history-hydration'
51
57
  import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
52
- import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
53
- import { flattenAssistantContent } from '../utils/flatten-assistant-content'
54
58
  import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
55
59
  import { defaultTableIdForDocumentType } from '../utils/source-icons'
56
60
  import type {
@@ -146,6 +150,10 @@ export interface ChatTurnMeta {
146
150
  /** Routing decision from the server's `decideRoute`. */
147
151
  routedComplexity: string | null
148
152
  routedThinkingBudget: number | null
153
+ /** Server-computed conversation trace id (`chat_conversations.id` for this
154
+ * thread), echoed in the leading metadata frame. Stable across turns of
155
+ * one thread — the correlation handle for transcripts/analytics. */
156
+ conversationId: string | null
149
157
  }
150
158
 
151
159
  /**
@@ -199,6 +207,7 @@ function createEmptyTurnMeta(): ChatTurnMeta {
199
207
  scrollAnchor: null,
200
208
  routedComplexity: null,
201
209
  routedThinkingBudget: null,
210
+ conversationId: null,
202
211
  }
203
212
  }
204
213
 
@@ -215,13 +224,14 @@ function escapeThinkingTags(text: string): string {
215
224
  function createDocStreamFn(
216
225
  source: DocSource,
217
226
  endpoints: { chatStreamUrl: string; approvalToolUrl: string },
218
- messagesRef: MutableRefObject<Message[]>,
219
227
  sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
220
228
  refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
221
229
  metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
222
230
  setStreamingPhase: (phase: StreamingPhase) => void,
223
231
  bumpMetaTick: () => void,
224
232
  sendCountRef: MutableRefObject<number>,
233
+ conversationIdRef: MutableRefObject<string | null>,
234
+ conversationStorage: LocalStorageAdapter<PersistedChatConversation>,
225
235
  ) {
226
236
  // CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
227
237
  // generator function (per-call closure), NOT at the factory level. A
@@ -233,22 +243,6 @@ function createDocStreamFn(
233
243
  signal?: AbortSignal,
234
244
  extra?: StreamFnExtraOptions,
235
245
  ): AsyncGenerator<MessageSegment> {
236
- const currentMessages = messagesRef.current || []
237
- // Filter `hidden:true` messages out of the API history. The approval-
238
- // action turn injects a hidden user message with `content=''`.
239
- // `flattenAssistantContent` joins text-segment arrays into a single
240
- // string so the server sees the receipt + auto-continuation Qs.
241
- const apiMessages = [
242
- ...currentMessages
243
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
244
- .map((m) => ({
245
- role: m.role,
246
- content:
247
- typeof m.content === 'string' ? m.content : flattenAssistantContent(m.content),
248
- })),
249
- { role: 'user', content: message },
250
- ]
251
-
252
246
  // URL + body branch — approvalAction routes to the approval-tool
253
247
  // endpoint, the standard chat path routes to the chat-stream endpoint.
254
248
  const targetPath = extra?.approvalAction
@@ -258,26 +252,30 @@ function createDocStreamFn(
258
252
  // it server-side via its own platform-detection — tamper-proof binding
259
253
  // so a client on one platform can't POST a different platform's
260
254
  // conversation.
255
+ //
256
+ // The server is the single source of conversation history: it re-reads
257
+ // `chat_messages` by conversation id on every turn. The wire therefore
258
+ // carries ONLY the new user message — never the prior conversation.
259
+ // The conversation id is SERVER-minted: the first message of a session
260
+ // sends none, the server mints one and echoes it in the leading metadata
261
+ // frame (captured below into `conversationIdRef`), and every later turn
262
+ // echoes it back.
263
+ const conversationId = conversationIdRef.current
261
264
  const requestBody = extra?.approvalAction
262
265
  ? {
263
266
  proposal_id: extra.approvalAction.proposalId,
264
267
  action: extra.approvalAction.action,
265
- messages: currentMessages
266
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
267
- .map((m) => ({
268
- role: m.role,
269
- content:
270
- typeof m.content === 'string'
271
- ? m.content
272
- : flattenAssistantContent(m.content),
273
- })),
268
+ // Always present here — an approval can only happen inside an
269
+ // established conversation (the proposal turn captured the id).
270
+ conversationId,
274
271
  }
275
272
  : {
276
- messages: apiMessages,
273
+ messages: [{ role: 'user', content: message }],
277
274
  ...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
278
275
  ...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
279
276
  ? { pendingAttachments: extra.pendingAttachments }
280
277
  : {}),
278
+ ...(conversationId ? { conversationId } : {}),
281
279
  }
282
280
  // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
283
281
  // session cookies) — same wrapper `use-chat-attachments` and
@@ -489,6 +487,18 @@ function createDocStreamFn(
489
487
  })
490
488
  bumpMetaTick()
491
489
  }
490
+ // Server-minted conversation id, echoed on every turn. On the
491
+ // session's FIRST turn this is where the client learns its id —
492
+ // capture it and persist it so later turns and future visits
493
+ // continue the same conversation.
494
+ if (typeof meta.conversationId === 'string' && meta.conversationId) {
495
+ if (conversationIdRef.current !== meta.conversationId) {
496
+ conversationIdRef.current = meta.conversationId
497
+ conversationStorage.save({ conversationId: meta.conversationId })
498
+ }
499
+ mergeTurnMeta(metaMapRef, sendIdx, { conversationId: meta.conversationId })
500
+ bumpMetaTick()
501
+ }
492
502
  const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
493
503
  if (parsedAnchor !== null) {
494
504
  mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
@@ -587,94 +597,25 @@ function mergeTurnMeta(
587
597
  }
588
598
 
589
599
  // =============================================================================
590
- // localStorage persistence
600
+ // Local persistence — the server-issued conversation id ONLY
591
601
  // =============================================================================
602
+ //
603
+ // The ONLY thing persisted locally is the conversation id the SERVER minted
604
+ // and echoed in the leading metadata frame of the session's first turn.
605
+ // The client never generates ids. Message history lives server-side in
606
+ // `chat_conversations` / `chat_messages` (recorded turn-by-turn by the chat
607
+ // route) and is rehydrated on mount via `GET <chatStreamUrl>/history`.
608
+ // localStorage never stores messages, sources, refs, or send counts —
609
+ // the server transcript is the single source of truth for history.
592
610
 
593
- const CHAT_STORAGE_VERSION = 1
594
-
595
- /** localStorage history namespace used when no `source` is configured on the
611
+ /** localStorage namespace used when no `source` is configured on the
596
612
  * runtime. Embedders are platform-agnostic (see `ChatRuntime.source`), so any
597
613
  * stable string works here — the hub passes its real platform instead. */
598
614
  const DEFAULT_CHAT_SOURCE = 'embed'
599
615
 
600
- /** Storage key — includes the proxy-auth impersonation email when
601
- * present so each impersonated customer keeps a SEPARATE chat history. */
602
- const chatStorageKey = (source: DocSource): string => {
603
- const base = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
604
- const auth = getChatProxyAuth()
605
- if (auth?.email) {
606
- return `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}`
607
- }
608
- return base
609
- }
610
-
611
- /** Sweep stale per-user chat-history keys. Drops any key whose email
612
- * differs from the CURRENT proxy-auth identity. */
613
- function pruneStaleChatStorage(source: DocSource): void {
614
- if (typeof window === 'undefined') return
615
- try {
616
- const currentKey = chatStorageKey(source)
617
- const prefix = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}-u-`
618
- const legacy = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
619
- const toRemove: string[] = []
620
- for (let i = 0; i < window.localStorage.length; i++) {
621
- const k = window.localStorage.key(i)
622
- if (!k) continue
623
- if (!k.startsWith(prefix)) continue
624
- if (k === currentKey) continue
625
- if (k === legacy) continue
626
- toRemove.push(k)
627
- }
628
- for (const k of toRemove) {
629
- window.localStorage.removeItem(k)
630
- }
631
- } catch {
632
- // localStorage access blocked (Safari private mode etc.) — non-fatal.
633
- }
634
- }
635
-
636
- interface PersistedChatState {
637
- messages: Message[]
638
- sources: Array<[number, ChatSource[]]>
639
- /** Per-turn refs for inline object cards. */
640
- refs?: Array<[number, Record<string, ChatRef>]>
641
- sendCount: number
642
- }
643
-
644
- function loadPersistedChat(source: DocSource): PersistedChatState | null {
645
- if (typeof window === 'undefined') return null
646
- try {
647
- const raw = window.localStorage.getItem(chatStorageKey(source))
648
- if (!raw) return null
649
- const parsed = JSON.parse(raw) as PersistedChatState
650
- if (!parsed || !Array.isArray(parsed.messages)) return null
651
- // Rehydrate Date objects + run forward-compat migrations.
652
- for (const m of parsed.messages) {
653
- if (typeof m.timestamp === 'string') m.timestamp = new Date(m.timestamp)
654
- // Forward-migration for auto-continuation directive bubbles.
655
- if (
656
- m.role === 'user' &&
657
- !m.hidden &&
658
- typeof m.content === 'string' &&
659
- m.content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
660
- ) {
661
- m.hidden = true
662
- }
663
- }
664
- return parsed
665
- } catch {
666
- return null
667
- }
668
- }
669
-
670
- function savePersistedChat(source: DocSource, state: PersistedChatState) {
671
- if (typeof window === 'undefined') return
672
- try {
673
- window.localStorage.setItem(chatStorageKey(source), JSON.stringify(state))
674
- } catch {
675
- // Quota exceeded or private mode — silently drop.
676
- }
677
- }
616
+ // Persistence itself lives in `../utils/chat-conversation-storage.ts` (built
617
+ // on the lib-standard `createLocalStorageAdapter`); the mount-time history
618
+ // rebuild lives in `./use-chat-history-hydration.ts`.
678
619
 
679
620
  // =============================================================================
680
621
  // useSseChatAdapter — public hook
@@ -698,10 +639,10 @@ export function useSseChatAdapter(
698
639
  // (hub) / embedder's provider must wrap the tree.
699
640
  const runtime = useRequiredChatRuntime()
700
641
  // `source` is OPTIONAL — embedders are platform-agnostic (see ChatRuntime.source).
701
- // Here it's used ONLY for the localStorage history namespace + client-side meta
702
- // labels; it is NEVER sent on the wire (the hub resolves source server-side via
703
- // currentPlatform()). Fall back to a stable constant so the persistence key stays
704
- // well-formed when the embedder leaves source unset.
642
+ // Here it's used ONLY for the conversation-id localStorage namespace + client-side
643
+ // meta labels; it is NEVER sent on the wire (the hub resolves source server-side
644
+ // via currentPlatform()). Fall back to a stable constant so the persistence key
645
+ // stays well-formed when the embedder leaves source unset.
705
646
  const source = runtime.source || DEFAULT_CHAT_SOURCE
706
647
  // Fall back to the lib-baked hub-canonical map when the embedder
707
648
  // didn't supply an override. Keeps Ask + Display working in any
@@ -711,23 +652,24 @@ export function useSseChatAdapter(
711
652
  const tableIdForDocumentType =
712
653
  options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
713
654
 
714
- // Restore persisted state once on mount.
715
- const persistedRef = useRef<PersistedChatState | null>(null)
716
- if (persistedRef.current === null) {
717
- pruneStaleChatStorage(source)
718
- persistedRef.current =
719
- loadPersistedChat(source) || { messages: [], sources: [], sendCount: 0 }
655
+ // Restore the server-issued conversation id once on mount. Null = no
656
+ // conversation yet — the FIRST send goes out without an id, the server
657
+ // mints one, and the metadata-frame capture (in the stream fn) stores it.
658
+ // The client NEVER generates ids. This is the ONLY local persistence —
659
+ // message history hydrates from the server transcript below.
660
+ const conversationStorage = useMemo(() => createChatConversationStorage(source), [source])
661
+ const conversationIdRef = useRef<string | null>(null)
662
+ const restoredConversationRef = useRef(false)
663
+ if (!restoredConversationRef.current) {
664
+ restoredConversationRef.current = true
665
+ pruneStaleChatConversationStorage(source)
666
+ conversationIdRef.current = conversationStorage.load()?.conversationId ?? null
720
667
  }
721
668
 
722
- const sourcesMapRef = useRef<Map<number, ChatSource[]>>(
723
- new Map(persistedRef.current.sources),
724
- )
725
- const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(
726
- new Map(persistedRef.current.refs ?? []),
727
- )
669
+ const sourcesMapRef = useRef<Map<number, ChatSource[]>>(new Map())
670
+ const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(new Map())
728
671
  const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
729
- const messagesRef = useRef<Message[]>(persistedRef.current.messages)
730
- const sendCountRef = useRef(persistedRef.current.sendCount)
672
+ const sendCountRef = useRef(0)
731
673
  const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
732
674
  const [metaTick, setMetaTick] = useState(0)
733
675
  const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
@@ -740,19 +682,21 @@ export function useSseChatAdapter(
740
682
  chatStreamUrl: runtime.endpoints.chatStreamUrl,
741
683
  approvalToolUrl: runtime.endpoints.approvalToolUrl,
742
684
  },
743
- messagesRef,
744
685
  sourcesMapRef,
745
686
  refsMapRef,
746
687
  metaMapRef,
747
688
  setStreamingPhase,
748
689
  bumpMetaTick,
749
690
  sendCountRef,
691
+ conversationIdRef,
692
+ conversationStorage,
750
693
  ),
751
694
  [
752
695
  source,
753
696
  runtime.endpoints.chatStreamUrl,
754
697
  runtime.endpoints.approvalToolUrl,
755
698
  bumpMetaTick,
699
+ conversationStorage,
756
700
  ],
757
701
  )
758
702
 
@@ -792,20 +736,6 @@ export function useSseChatAdapter(
792
736
  return map
793
737
  }, [slashCommands])
794
738
 
795
- // Persist on every messages change. Sources + sendCount live in refs,
796
- // so we read their current values at write time.
797
- const persist = useCallback(
798
- (nextMessages: Message[]) => {
799
- savePersistedChat(source, {
800
- messages: nextMessages,
801
- sources: Array.from(sourcesMapRef.current.entries()),
802
- refs: Array.from(refsMapRef.current.entries()),
803
- sendCount: sendCountRef.current,
804
- })
805
- },
806
- [source],
807
- )
808
-
809
739
  const {
810
740
  messages,
811
741
  isTyping,
@@ -813,16 +743,32 @@ export function useSseChatAdapter(
813
743
  sendMessage: chatSendMessage,
814
744
  stopMessage: chatStopMessage,
815
745
  clearMessages: chatClearMessages,
746
+ hydrateMessages,
816
747
  hasMessages,
817
748
  } = useChat({
818
749
  useMock: false,
819
750
  assistantName: 'Mingo AI',
820
751
  streamFn,
821
- initialMessages: persistedRef.current.messages,
822
- onMessagesChange: persist,
823
752
  })
824
753
 
825
- messagesRef.current = messages
754
+ // ─── Server hydration (history SSOT) ───
755
+ // Mount-time rebuild of the message list from the server transcript — the
756
+ // single store of conversation history. Extracted to its own hook; see
757
+ // `use-chat-history-hydration.ts` for the full contract + failure
758
+ // semantics (a miss starts the UI empty, never loses server context).
759
+ const historyUrl =
760
+ runtime.endpoints.chatHistoryUrl ??
761
+ `${runtime.endpoints.chatStreamUrl.replace(/\/+$/, '')}/history`
762
+ const { isHydratingHistory, hydratedKeyRef } = useChatHistoryHydration({
763
+ active,
764
+ source,
765
+ historyUrl,
766
+ conversationIdRef,
767
+ refsMapRef,
768
+ sendCountRef,
769
+ hydrateMessages,
770
+ bumpMetaTick,
771
+ })
826
772
 
827
773
  // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
828
774
  // by assistant-message count. Each user send produces exactly ONE
@@ -1008,17 +954,17 @@ export function useSseChatAdapter(
1008
954
  refsMapRef.current.clear()
1009
955
  metaMapRef.current.clear()
1010
956
  sendCountRef.current = 0
957
+ // New chat = drop the stored conversation id. The old row stays frozen
958
+ // server-side; the NEXT send goes out id-less and the server mints a
959
+ // fresh conversation (echoed back and re-captured then).
960
+ conversationIdRef.current = null
961
+ conversationStorage.clear()
962
+ hydratedKeyRef.current = null
1011
963
  setStreamingPhase('idle')
1012
964
  // Force the latestMeta useMemo to recompute with the cleared map.
1013
965
  bumpMetaTick()
1014
966
  chatClearMessages()
1015
- // Clear persisted state too so the next mount starts fresh.
1016
- if (typeof window !== 'undefined') {
1017
- try {
1018
- window.localStorage.removeItem(chatStorageKey(source))
1019
- } catch {}
1020
- }
1021
- }, [chatClearMessages, source, bumpMetaTick])
967
+ }, [chatClearMessages, conversationStorage, hydratedKeyRef, bumpMetaTick])
1022
968
 
1023
969
  // Reset to idle whenever both flags drop off.
1024
970
  useEffect(() => {
@@ -1047,6 +993,8 @@ export function useSseChatAdapter(
1047
993
  stopMessage,
1048
994
  clearMessages,
1049
995
  streamingPhase,
996
+ /** True while the mount-time rebuild from the server transcript runs. */
997
+ isHydratingHistory,
1050
998
  /** Provider key for the lib's `<ModelDisplay>` icon. */
1051
999
  currentProvider: latestMeta?.provider ?? null,
1052
1000
  currentModelLabel: latestMeta?.modelLabel ?? null,
@@ -1061,12 +1009,11 @@ export function useSseChatAdapter(
1061
1009
  * token counts). null until the trailing usage frame lands. */
1062
1010
  currentUsageBreakdown: latestMeta?.breakdown ?? null,
1063
1011
  // ─── Dialog management — stubs for v1 ────────────────────────────────
1064
- // Guide mode currently keeps its history in `localStorage` opaquely
1065
- // under the hood (`runtime.source` namespaced key). Surfacing that
1066
- // history as a structured dialog list is a follow-up; for now the
1067
- // shape is satisfied with empty defaults so the unified contract
1068
- // type-checks and EmbeddableChat hides sidebar affordances when
1069
- // `dialogs.length === 0`.
1012
+ // Guide mode keeps ONE server-side conversation per stored id
1013
+ // (`chat_conversations`, hydrated on mount). Surfacing multiple threads
1014
+ // as a structured dialog list is a follow-up; for now the shape is
1015
+ // satisfied with empty defaults so the unified contract type-checks and
1016
+ // EmbeddableChat hides sidebar affordances when `dialogs.length === 0`.
1070
1017
  dialogs: SSE_EMPTY_DIALOGS,
1071
1018
  activeDialogId: null,
1072
1019
  selectDialog: noopSelectDialog,
@@ -6,6 +6,7 @@ export * from './approval-batch-message'
6
6
  export * from './context-compaction-display'
7
7
  export * from './expand-chevron'
8
8
  export * from './thinking-display'
9
+ export * from './guide-display'
9
10
  export * from './error-message-display'
10
11
  export * from './chat-container'
11
12
  export * from './chat-input'
@@ -556,6 +556,15 @@ export interface ThinkingDisplayProps extends HTMLAttributes<HTMLDivElement> {
556
556
  isStreaming?: boolean
557
557
  }
558
558
 
559
+ // ========== Guide Display Props ==========
560
+
561
+ export interface GuideDisplayProps extends HTMLAttributes<HTMLDivElement> {
562
+ /** Rendered guide body. `ChatMessageEnhanced` passes the SAME markdown
563
+ * output a text segment gets, so `[card://]` cards and mention chips
564
+ * behave identically inside a guide. */
565
+ children?: ReactNode
566
+ }
567
+
559
568
  // ========== Model Display Props ==========
560
569
 
561
570
  export interface ModelDisplayProps extends HTMLAttributes<HTMLDivElement> {
@@ -10,6 +10,7 @@ import type { AssistantType, AuthorType, ChatApprovalStatus, MessageOwner } from
10
10
  export const MESSAGE_TYPE = {
11
11
  TEXT: 'TEXT',
12
12
  THINKING: 'THINKING',
13
+ GUIDE: 'GUIDE',
13
14
  EXECUTING_TOOL: 'EXECUTING_TOOL',
14
15
  EXECUTED_TOOL: 'EXECUTED_TOOL',
15
16
  APPROVAL_REQUEST: 'APPROVAL_REQUEST',
@@ -170,6 +171,15 @@ export type ThinkingSegment = {
170
171
  text: string
171
172
  }
172
173
 
174
+ /** Guide answer body — the assistant's how-to/documentation reply, rendered as
175
+ * a titled "OpenFrame Guide" card instead of a bare paragraph. `text` is
176
+ * markdown, streamed in fragments like a `text` segment and coalesced by the
177
+ * accumulator. */
178
+ export type GuideSegment = {
179
+ type: 'guide'
180
+ text: string
181
+ }
182
+
173
183
  export type ToolExecutionSegment = {
174
184
  type: 'tool_execution'
175
185
  data: ToolExecutionData
@@ -208,7 +218,7 @@ export type ContextCompactionSegment = {
208
218
  summary?: string
209
219
  }
210
220
 
211
- export type MessageSegment = TextSegment | ThinkingSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
221
+ export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
212
222
 
213
223
  export type MessageContent = string | MessageSegment[]
214
224
 
@@ -228,6 +238,11 @@ export interface ThinkingMessageData extends MessageDataBase {
228
238
  text?: string
229
239
  }
230
240
 
241
+ export interface GuideMessageData extends MessageDataBase {
242
+ type: 'GUIDE'
243
+ text?: string
244
+ }
245
+
231
246
  export interface ExecutingToolMessageData extends MessageDataBase {
232
247
  type: 'EXECUTING_TOOL'
233
248
  integratedToolType?: string
@@ -309,6 +324,7 @@ export interface ContextCompactionEndMessageData extends MessageDataBase {
309
324
  export type MessageData =
310
325
  | TextMessageData
311
326
  | ThinkingMessageData
327
+ | GuideMessageData
312
328
  | ExecutingToolMessageData
313
329
  | ExecutedToolMessageData
314
330
  | ApprovalRequestMessageData
@@ -16,6 +16,7 @@ export type ParsedChunkAction =
16
16
  | { action: 'metadata'; modelDisplayName: string; modelName: string; providerName: string; contextWindow: number }
17
17
  | { action: 'text'; text: string }
18
18
  | { action: 'thinking'; text: string }
19
+ | { action: 'guide'; text: string }
19
20
  | { action: 'tool_execution'; segment: ToolExecutionSegment }
20
21
  | { action: 'approval_request'; requestId: string; command: string; explanation?: string; approvalType: string }
21
22
  | { action: 'approval_batch'; requestId: string; approvalType: string; toolCalls: PendingToolCallData[] }
@@ -251,6 +251,12 @@ export interface UnifiedChatState {
251
251
  /** Granular phase for the "Thinking..."/"Streaming..." status row above input. */
252
252
  streamingPhase: StreamingPhase
253
253
 
254
+ /** True while the adapter is rebuilding the message list from the
255
+ * server-side transcript store (SSE/Guide mount-time hydration). Optional —
256
+ * transports without server hydration (NATS manages its own dialog
257
+ * loading) never set it. */
258
+ isHydratingHistory?: boolean
259
+
254
260
  // ─── Actions ──────────────────────────────────────────────────────────────
255
261
  sendMessage: (
256
262
  text: string,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Chat conversation-id persistence — the SSE/Guide chat's ONLY local state.
3
+ *
4
+ * The stored value is the SERVER-minted conversation id echoed in the chat
5
+ * stream's leading metadata frame; the client never generates ids. Message
6
+ * history itself lives server-side (`chat_conversations` / `chat_messages`)
7
+ * and is rehydrated via the history endpoint — see
8
+ * `use-chat-history-hydration.ts`.
9
+ *
10
+ * Built on the lib-standard `createLocalStorageAdapter` (SSR guard,
11
+ * try/catch, quota-failure logging, dynamic namespacing) instead of raw
12
+ * `window.localStorage`. The namespace resolver runs on EVERY read/write, so
13
+ * a proxy-auth identity switch mid-page automatically re-partitions the key.
14
+ *
15
+ * Key shape: `mingo-chat-<source>[-u-<email>].conversation`
16
+ */
17
+
18
+ import {
19
+ createLocalStorageAdapter,
20
+ type LocalStorageAdapter,
21
+ } from '../../../utils/local-storage-adapter'
22
+ import { getChatProxyAuth } from './chat-proxy-auth-storage'
23
+
24
+ export interface PersistedChatConversation {
25
+ conversationId: string
26
+ }
27
+
28
+ const STORAGE_KEY = 'conversation'
29
+
30
+ const namespaceFor = (source: string): string => {
31
+ const base = `mingo-chat-${source}`
32
+ const auth = getChatProxyAuth()
33
+ return auth?.email ? `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}` : base
34
+ }
35
+
36
+ /** One adapter per chat `source` (= platform). Memoize per mount. */
37
+ export function createChatConversationStorage(
38
+ source: string,
39
+ ): LocalStorageAdapter<PersistedChatConversation> {
40
+ return createLocalStorageAdapter<PersistedChatConversation>({
41
+ key: STORAGE_KEY,
42
+ namespace: () => namespaceFor(source),
43
+ validate: (parsed): parsed is PersistedChatConversation =>
44
+ !!parsed &&
45
+ typeof (parsed as PersistedChatConversation).conversationId === 'string' &&
46
+ (parsed as PersistedChatConversation).conversationId.length > 0,
47
+ logTag: '[chat-conversation-storage]',
48
+ })
49
+ }
50
+
51
+ /**
52
+ * Sweep THIS source's stale keys: the other-identity variants (base vs
53
+ * impersonation), and retired key shapes from before the server-minted-id
54
+ * design (raw `mingo-chat-<source>` and versioned `-v1`/`-v2` full-history
55
+ * stores). Boundary-aware: a hyphen-extended sibling source (`flamingo` vs
56
+ * `flamingo-teaser`) is NOT this source's namespace and must never be swept.
57
+ *
58
+ * Raw storage enumeration is unavoidable here — the adapter abstraction is
59
+ * per-key and Web Storage only exposes iteration on the raw object.
60
+ */
61
+ export function pruneStaleChatConversationStorage(source: string): void {
62
+ if (typeof window === 'undefined') return
63
+ try {
64
+ const base = `mingo-chat-${source}`
65
+ const currentKey = `${namespaceFor(source)}.${STORAGE_KEY}`
66
+ const ownedKey = (k: string) =>
67
+ k === base || // retired raw key (pre-adapter shape)
68
+ k === `${base}.${STORAGE_KEY}` || // current no-identity shape
69
+ k.startsWith(`${base}-u-`) || // per-identity shapes (current + retired)
70
+ k.startsWith(`${base}-v`) // retired versioned full-history shapes
71
+ const toRemove: string[] = []
72
+ for (let i = 0; i < window.localStorage.length; i++) {
73
+ const k = window.localStorage.key(i)
74
+ if (!k) continue
75
+ if (ownedKey(k) && k !== currentKey) toRemove.push(k)
76
+ }
77
+ for (const k of toRemove) {
78
+ window.localStorage.removeItem(k)
79
+ }
80
+ } catch {
81
+ // localStorage access blocked (Safari private mode etc.) — non-fatal.
82
+ }
83
+ }
@@ -65,6 +65,12 @@ export function parseChunkToAction(chunk: unknown): ParsedChunkAction | null {
65
65
  }
66
66
  return null
67
67
 
68
+ case MESSAGE_TYPE.GUIDE:
69
+ if (typeof data.text === 'string') {
70
+ return { action: 'guide', text: data.text }
71
+ }
72
+ return null
73
+
68
74
  case MESSAGE_TYPE.EXECUTING_TOOL:
69
75
  return {
70
76
  action: 'tool_execution',
@@ -139,6 +139,22 @@ export class MessageSegmentAccumulator {
139
139
  return this.getSegments()
140
140
  }
141
141
 
142
+ /**
143
+ * Append guide text to the current message.
144
+ * If the last segment is guide, append to it; otherwise start a new guide segment.
145
+ */
146
+ appendGuide(text: string): MessageSegment[] {
147
+ const lastSegment = this.segments[this.segments.length - 1]
148
+
149
+ if (lastSegment && lastSegment.type === 'guide') {
150
+ this.segments[this.segments.length - 1] = { type: 'guide', text: lastSegment.text + text }
151
+ } else {
152
+ this.segments.push({ type: 'guide', text })
153
+ }
154
+
155
+ return this.getSegments()
156
+ }
157
+
142
158
  /**
143
159
  * Add a tool execution segment.
144
160
  *
@@ -516,6 +532,9 @@ export class MessageSegmentAccumulator {
516
532
  case 'thinking':
517
533
  if (segment.text) this.appendThinking(segment.text)
518
534
  break
535
+ case 'guide':
536
+ if (segment.text) this.appendGuide(segment.text)
537
+ break
519
538
  case 'tool_execution':
520
539
  this.addToolExecution(segment)
521
540
  break