@flamingo-stack/openframe-frontend-core 0.0.487 → 0.0.488-snapshot.20260728130942

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 (124) hide show
  1. package/dist/{chunk-4TFUB2CR.cjs → chunk-2IHN7NNB.cjs} +29 -29
  2. package/dist/{chunk-4TFUB2CR.cjs.map → chunk-2IHN7NNB.cjs.map} +1 -1
  3. package/dist/{chunk-UU4DW7JX.cjs → chunk-2RNJYJWO.cjs} +42 -42
  4. package/dist/{chunk-UU4DW7JX.cjs.map → chunk-2RNJYJWO.cjs.map} +1 -1
  5. package/dist/{chunk-W6GDTB4R.cjs → chunk-3OIB763B.cjs} +15 -15
  6. package/dist/{chunk-W6GDTB4R.cjs.map → chunk-3OIB763B.cjs.map} +1 -1
  7. package/dist/{chunk-NETEA5E4.js → chunk-5VSEMWWE.js} +6 -6
  8. package/dist/{chunk-K6MITU6M.cjs → chunk-5YMY4BAW.cjs} +77 -77
  9. package/dist/{chunk-K6MITU6M.cjs.map → chunk-5YMY4BAW.cjs.map} +1 -1
  10. package/dist/{chunk-KFQXO7QB.js → chunk-6GT557KN.js} +1 -1
  11. package/dist/chunk-6GT557KN.js.map +1 -0
  12. package/dist/{chunk-QBDHNUQX.js → chunk-B4YR342L.js} +6 -6
  13. package/dist/{chunk-SMD67GJP.js → chunk-BGWDDB7Y.js} +4 -4
  14. package/dist/{chunk-A7IPON2V.cjs → chunk-BIKOJ4FH.cjs} +5 -5
  15. package/dist/{chunk-A7IPON2V.cjs.map → chunk-BIKOJ4FH.cjs.map} +1 -1
  16. package/dist/{chunk-G4XU7BYU.js → chunk-EU6QNO4S.js} +2 -2
  17. package/dist/{chunk-ITEPRN6L.js → chunk-FKPXFTZT.js} +3 -3
  18. package/dist/{chunk-EO7MUGY4.cjs → chunk-H3YRJ3LZ.cjs} +14 -14
  19. package/dist/{chunk-EO7MUGY4.cjs.map → chunk-H3YRJ3LZ.cjs.map} +1 -1
  20. package/dist/{chunk-TJSJPOPL.cjs → chunk-IN4IO7BL.cjs} +395 -337
  21. package/dist/chunk-IN4IO7BL.cjs.map +1 -0
  22. package/dist/{chunk-HWK5CH5N.cjs → chunk-J7H32E3R.cjs} +18 -18
  23. package/dist/{chunk-HWK5CH5N.cjs.map → chunk-J7H32E3R.cjs.map} +1 -1
  24. package/dist/{chunk-BTMYUK4P.js → chunk-K7MNWYYI.js} +3 -3
  25. package/dist/{chunk-PECGZWQR.cjs → chunk-KP5KRRD6.cjs} +3 -3
  26. package/dist/{chunk-PECGZWQR.cjs.map → chunk-KP5KRRD6.cjs.map} +1 -1
  27. package/dist/{chunk-2B6773QM.cjs → chunk-LILQJ3UZ.cjs} +1 -1
  28. package/dist/chunk-LILQJ3UZ.cjs.map +1 -0
  29. package/dist/{chunk-RAYDQGJV.cjs → chunk-LLEDO4P7.cjs} +75 -75
  30. package/dist/{chunk-RAYDQGJV.cjs.map → chunk-LLEDO4P7.cjs.map} +1 -1
  31. package/dist/{chunk-AUGM74CO.js → chunk-N4MTNTSX.js} +7 -7
  32. package/dist/{chunk-KLYHZRAD.js → chunk-PB3BTZDU.js} +2 -2
  33. package/dist/{chunk-QW6SPNPT.cjs → chunk-RO7Q56ZK.cjs} +40 -40
  34. package/dist/{chunk-QW6SPNPT.cjs.map → chunk-RO7Q56ZK.cjs.map} +1 -1
  35. package/dist/{chunk-CWQACI4Z.js → chunk-RW2LPL72.js} +2 -2
  36. package/dist/{chunk-SDNMOAT4.js → chunk-TWGYBXYV.js} +4 -4
  37. package/dist/{chunk-WLRBZXFB.js → chunk-VL4LUO32.js} +3 -3
  38. package/dist/{chunk-QL7S3CH4.cjs → chunk-VLOE7MI2.cjs} +35 -35
  39. package/dist/{chunk-QL7S3CH4.cjs.map → chunk-VLOE7MI2.cjs.map} +1 -1
  40. package/dist/{chunk-UBK5HIMH.js → chunk-W6ZUOWSB.js} +7 -7
  41. package/dist/{chunk-GXOUXAXU.js → chunk-X2YF5TJS.js} +2 -2
  42. package/dist/{chunk-BC74IKAX.js → chunk-YMJZOANX.js} +277 -219
  43. package/dist/chunk-YMJZOANX.js.map +1 -0
  44. package/dist/{chunk-6SBAF5VG.cjs → chunk-Z7Y63IFU.cjs} +7 -7
  45. package/dist/{chunk-6SBAF5VG.cjs.map → chunk-Z7Y63IFU.cjs.map} +1 -1
  46. package/dist/{chunk-KTD2MGYF.cjs → chunk-ZIAS5NNL.cjs} +7 -7
  47. package/dist/{chunk-KTD2MGYF.cjs.map → chunk-ZIAS5NNL.cjs.map} +1 -1
  48. package/dist/components/case-studies/index.cjs +10 -10
  49. package/dist/components/case-studies/index.js +4 -4
  50. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts +47 -0
  51. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts.map +1 -0
  52. package/dist/components/chat/hooks/use-chat.d.ts +1 -0
  53. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  54. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +4 -0
  55. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +4 -4
  57. package/dist/components/chat/index.js +3 -3
  58. package/dist/components/chat/types/unified-chat-state.types.d.ts +5 -0
  59. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  60. package/dist/components/chat/utils/chat-conversation-storage.d.ts +34 -0
  61. package/dist/components/chat/utils/chat-conversation-storage.d.ts.map +1 -0
  62. package/dist/components/contact/index.cjs +5 -5
  63. package/dist/components/contact/index.js +4 -4
  64. package/dist/components/docs/index.cjs +7 -7
  65. package/dist/components/docs/index.js +6 -6
  66. package/dist/components/embeds/index.cjs +5 -5
  67. package/dist/components/embeds/index.js +4 -4
  68. package/dist/components/faq/index.cjs +6 -6
  69. package/dist/components/faq/index.js +5 -5
  70. package/dist/components/features/index.cjs +4 -4
  71. package/dist/components/features/index.js +3 -3
  72. package/dist/components/help-center-pages/index.cjs +23 -23
  73. package/dist/components/help-center-pages/index.js +14 -14
  74. package/dist/components/index.cjs +140 -140
  75. package/dist/components/index.js +12 -12
  76. package/dist/components/navigation/index.cjs +4 -4
  77. package/dist/components/navigation/index.js +3 -3
  78. package/dist/components/onboarding-guides/index.cjs +8 -8
  79. package/dist/components/onboarding-guides/index.js +7 -7
  80. package/dist/components/related-content/index.cjs +6 -6
  81. package/dist/components/related-content/index.js +5 -5
  82. package/dist/components/tickets/index.cjs +7 -7
  83. package/dist/components/tickets/index.js +6 -6
  84. package/dist/components/ui/index.cjs +4 -4
  85. package/dist/components/ui/index.js +3 -3
  86. package/dist/components/ui/query-report-table/query-report-table.d.ts.map +1 -1
  87. package/dist/components/ui/query-report-table/types.d.ts +2 -2
  88. package/dist/components/ui/query-report-table/types.d.ts.map +1 -1
  89. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  90. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  91. package/dist/contexts/index.cjs +2 -2
  92. package/dist/contexts/index.js +1 -1
  93. package/dist/hooks/index.cjs +3 -3
  94. package/dist/hooks/index.js +2 -2
  95. package/dist/index.cjs +4 -4
  96. package/dist/index.js +3 -3
  97. package/package.json +1 -1
  98. package/src/components/chat/hooks/use-chat-history-hydration.ts +142 -0
  99. package/src/components/chat/hooks/use-chat.ts +12 -0
  100. package/src/components/chat/hooks/use-sse-chat-adapter.ts +109 -162
  101. package/src/components/chat/types/unified-chat-state.types.ts +6 -0
  102. package/src/components/chat/utils/chat-conversation-storage.ts +83 -0
  103. package/src/components/features/notifications/notification-drawer.tsx +1 -1
  104. package/src/components/ui/query-report-table/query-report-table-skeleton.tsx +1 -1
  105. package/src/components/ui/query-report-table/query-report-table.tsx +7 -8
  106. package/src/components/ui/query-report-table/types.ts +2 -2
  107. package/src/contexts/chat-runtime-context.tsx +8 -0
  108. package/dist/chunk-2B6773QM.cjs.map +0 -1
  109. package/dist/chunk-BC74IKAX.js.map +0 -1
  110. package/dist/chunk-KFQXO7QB.js.map +0 -1
  111. package/dist/chunk-TJSJPOPL.cjs.map +0 -1
  112. /package/dist/{chunk-NETEA5E4.js.map → chunk-5VSEMWWE.js.map} +0 -0
  113. /package/dist/{chunk-QBDHNUQX.js.map → chunk-B4YR342L.js.map} +0 -0
  114. /package/dist/{chunk-SMD67GJP.js.map → chunk-BGWDDB7Y.js.map} +0 -0
  115. /package/dist/{chunk-G4XU7BYU.js.map → chunk-EU6QNO4S.js.map} +0 -0
  116. /package/dist/{chunk-ITEPRN6L.js.map → chunk-FKPXFTZT.js.map} +0 -0
  117. /package/dist/{chunk-BTMYUK4P.js.map → chunk-K7MNWYYI.js.map} +0 -0
  118. /package/dist/{chunk-AUGM74CO.js.map → chunk-N4MTNTSX.js.map} +0 -0
  119. /package/dist/{chunk-KLYHZRAD.js.map → chunk-PB3BTZDU.js.map} +0 -0
  120. /package/dist/{chunk-CWQACI4Z.js.map → chunk-RW2LPL72.js.map} +0 -0
  121. /package/dist/{chunk-SDNMOAT4.js.map → chunk-TWGYBXYV.js.map} +0 -0
  122. /package/dist/{chunk-WLRBZXFB.js.map → chunk-VL4LUO32.js.map} +0 -0
  123. /package/dist/{chunk-UBK5HIMH.js.map → chunk-W6ZUOWSB.js.map} +0 -0
  124. /package/dist/{chunk-GXOUXAXU.js.map → chunk-X2YF5TJS.js.map} +0 -0
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * useChatHistoryHydration — mount-time rebuild of the chat message list from
5
+ * the SERVER-side conversation store (the `chat_conversations` /
6
+ * `chat_messages` SSOT behind `GET <chatStreamUrl>/history`).
7
+ *
8
+ * The client persists ONLY the server-issued conversation id (see
9
+ * `chat-conversation-storage.ts`); this hook turns that id back into rendered
10
+ * history: message bubbles, inline entity-card refs (per send index, same
11
+ * `sendIdx` scheme as live turns), and the send counter.
12
+ *
13
+ * Failure semantics: a fetch/parse failure simply leaves the UI empty — the
14
+ * SERVER still resolves full history for the next turn (it re-reads
15
+ * `chat_messages` by conversation id on every send), so conversation context
16
+ * is never lost when hydration misses.
17
+ */
18
+
19
+ import { useEffect, useRef, useState, type MutableRefObject } from 'react'
20
+ import type { ChatRef } from '../chat-ref.types'
21
+ import type { Message } from './use-chat'
22
+ import { chatAuthedFetch } from '../utils/chat-authed-fetch'
23
+ import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
24
+
25
+ export interface UseChatHistoryHydrationArgs {
26
+ /** Mirrors the adapter's `active` gate — an idle (Mingo-mode) mount never fetches. */
27
+ active: boolean
28
+ /** Chat source (= platform); part of the once-per-conversation guard key. */
29
+ source: string
30
+ /** Resolved history endpoint (`<chatStreamUrl>/history` by default). */
31
+ historyUrl: string
32
+ /** The server-issued conversation id (null = nothing to hydrate). */
33
+ conversationIdRef: MutableRefObject<string | null>
34
+ /** Per-send inline entity-card refs — repopulated from `chat_refs`. */
35
+ refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>
36
+ /** User-send counter — set to the hydrated user-turn count so the next
37
+ * live send lands on the following `sendIdx`. */
38
+ sendCountRef: MutableRefObject<number>
39
+ /** `useChat`'s injection primitive (replace-or-prepend). */
40
+ hydrateMessages: (history: Message[]) => void
41
+ /** Invalidates the adapter's `latestMeta` memo after refs repopulate. */
42
+ bumpMetaTick: () => void
43
+ }
44
+
45
+ export interface UseChatHistoryHydrationResult {
46
+ /** True while the rebuild request is in flight. */
47
+ isHydratingHistory: boolean
48
+ /** Once-per-`source:conversationId` guard. The adapter's `clearMessages`
49
+ * resets it to null alongside the stored id (a fresh conversation has no
50
+ * server history to fetch). */
51
+ hydratedKeyRef: MutableRefObject<string | null>
52
+ }
53
+
54
+ export function useChatHistoryHydration({
55
+ active,
56
+ source,
57
+ historyUrl,
58
+ conversationIdRef,
59
+ refsMapRef,
60
+ sendCountRef,
61
+ hydrateMessages,
62
+ bumpMetaTick,
63
+ }: UseChatHistoryHydrationArgs): UseChatHistoryHydrationResult {
64
+ const [isHydratingHistory, setIsHydratingHistory] = useState(false)
65
+ const hydratedKeyRef = useRef<string | null>(null)
66
+
67
+ useEffect(() => {
68
+ if (!active) return
69
+ // No stored conversation id → nothing to hydrate (fresh visitor / after
70
+ // "new chat"); the first send establishes the conversation server-side.
71
+ const conversationId = conversationIdRef.current
72
+ if (!conversationId) return
73
+ const key = `${source}:${conversationId}`
74
+ if (hydratedKeyRef.current === key) return
75
+ hydratedKeyRef.current = key
76
+ let cancelled = false
77
+ setIsHydratingHistory(true)
78
+ ;(async () => {
79
+ try {
80
+ const res = await chatAuthedFetch(
81
+ `${historyUrl}?conversationId=${encodeURIComponent(conversationId)}`,
82
+ { method: 'GET' },
83
+ )
84
+ if (!res.ok) return
85
+ const payload = await res.json().catch(() => null)
86
+ // route-base successResponse envelope ({ data }) with a raw-body fallback.
87
+ const body = (payload?.data ?? payload) as
88
+ | { messages?: Array<Record<string, unknown>> }
89
+ | null
90
+ const rows = Array.isArray(body?.messages) ? body!.messages! : []
91
+ if (cancelled || rows.length === 0) return
92
+ const hydrated: Message[] = []
93
+ let userTurns = 0
94
+ for (const row of rows) {
95
+ const role =
96
+ row.role === 'assistant' ? 'assistant' : row.role === 'user' ? 'user' : null
97
+ if (!role) continue
98
+ const content = typeof row.content === 'string' ? row.content : ''
99
+ // The approval placeholder ('') and the server-built auto-continuation
100
+ // directive are part of the LLM history but never rendered.
101
+ const hidden =
102
+ role === 'user' &&
103
+ (content === '' || content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX))
104
+ if (role === 'user') {
105
+ userTurns += 1
106
+ } else if (row.chat_refs && typeof row.chat_refs === 'object') {
107
+ // Re-attach inline entity-card refs at the send index this
108
+ // assistant row belongs to (same sendIdx scheme as live turns).
109
+ refsMapRef.current.set(
110
+ Math.max(0, userTurns - 1),
111
+ row.chat_refs as Record<string, ChatRef>,
112
+ )
113
+ }
114
+ hydrated.push({
115
+ id: `hydrated-${String(row.seq ?? hydrated.length)}`,
116
+ role,
117
+ content,
118
+ ...(typeof row.created_at === 'string'
119
+ ? { timestamp: new Date(row.created_at) }
120
+ : {}),
121
+ ...(hidden ? { hidden: true } : {}),
122
+ } as Message)
123
+ }
124
+ if (cancelled || hydrated.length === 0) return
125
+ sendCountRef.current = userTurns
126
+ hydrateMessages(hydrated)
127
+ bumpMetaTick()
128
+ } catch {
129
+ // Fetch failed — start empty; the server still owns history (above).
130
+ } finally {
131
+ if (!cancelled) setIsHydratingHistory(false)
132
+ }
133
+ })()
134
+ return () => {
135
+ cancelled = true
136
+ }
137
+ // Refs are stable across renders — the effect keys on the identity-ish deps only.
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [active, source, historyUrl, hydrateMessages, bumpMetaTick])
140
+
141
+ return { isHydratingHistory, hydratedKeyRef }
142
+ }
@@ -476,6 +476,17 @@ export function useChat({
476
476
  reset()
477
477
  }, [reset])
478
478
 
479
+ /**
480
+ * Replace-or-prepend server-hydrated history. Called once by the SSE
481
+ * adapter after `GET /api/docs/chat/history` resolves. If the user already
482
+ * sent a message before hydration landed, the fetched history is PREPENDED
483
+ * so nothing typed is lost — correctness is unaffected either way because
484
+ * the server resolves LLM history from its own store, not from this state.
485
+ */
486
+ const hydrateMessages = useCallback((history: Message[]) => {
487
+ setMessages((prev) => (prev.length === 0 ? history : [...history, ...prev]))
488
+ }, [])
489
+
479
490
  /**
480
491
  * Abort the in-flight streamed message. The fetch's AbortSignal terminates
481
492
  * the upstream Anthropic request (so billing stops); the `for await` loop
@@ -496,6 +507,7 @@ export function useChat({
496
507
  stopMessage,
497
508
  handleQuickAction,
498
509
  clearMessages,
510
+ hydrateMessages,
499
511
  hasMessages: messages.length > 0,
500
512
  }
501
513
  }
@@ -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,
@@ -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,