@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,429 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Multi-tab AI chat sessions.
5
+ *
6
+ * Each agent can have several concurrent conversation threads (sessions).
7
+ * The provider stores their *metadata* — id, conversationId, optional
8
+ * user-given name, timestamps, status — in localStorage, so closing and
9
+ * re-opening the chat pane (or refreshing the page) restores the tabs and
10
+ * their history. The actual messages live in `useAiChat`'s per-conversation
11
+ * persistence slot keyed by `(agent, conversationId)`, which is why every
12
+ * session gets its own UUID even when it shares an agent with the next tab.
13
+ *
14
+ * Sessions are partitioned into:
15
+ * - `open` → currently shown in the tab strip
16
+ * - `closed` → hidden but kept for the history dropdown
17
+ *
18
+ * Closing a tab moves it to `closed`. Re-opening from history flips it back
19
+ * to `open` and surfaces it as the active tab. Renaming is a single
20
+ * `name` field; falling back to the formatted creation date when the user
21
+ * never named the session keeps the picker scannable.
22
+ */
23
+
24
+ import * as React from 'react'
25
+
26
+ const STORAGE_KEY = 'om-ai-chat-sessions-v1'
27
+ const HISTORY_LIMIT = 50
28
+
29
+ export type AiChatSessionStatus = 'open' | 'closed'
30
+
31
+ export interface AiChatSession {
32
+ id: string
33
+ agentId: string
34
+ conversationId: string
35
+ name?: string
36
+ createdAt: number
37
+ lastUsedAt: number
38
+ status: AiChatSessionStatus
39
+ }
40
+
41
+ interface AiChatSessionsState {
42
+ sessions: AiChatSession[]
43
+ activeByAgent: Record<string, string>
44
+ }
45
+
46
+ interface AiChatSessionsApi {
47
+ state: AiChatSessionsState
48
+ /** Returns open sessions for `agentId`, ordered by creation. */
49
+ getOpenSessions: (agentId: string) => AiChatSession[]
50
+ /** Returns closed sessions (history) for `agentId`, newest first. */
51
+ getClosedSessions: (agentId: string, limit?: number) => AiChatSession[]
52
+ /** Returns the active session for `agentId`, or null if none. */
53
+ getActiveSession: (agentId: string) => AiChatSession | null
54
+ /** Creates and activates a fresh open session for `agentId`. */
55
+ createSession: (agentId: string) => AiChatSession
56
+ /** Closes a session (moves to history). If it was active, picks another. */
57
+ closeSession: (sessionId: string) => void
58
+ /** Re-opens a closed session and activates it. */
59
+ reopenSession: (sessionId: string) => void
60
+ /** Activates an open session. */
61
+ setActiveSession: (sessionId: string) => void
62
+ /** Renames a session — empty / whitespace clears the custom name. */
63
+ renameSession: (sessionId: string, name: string) => void
64
+ /** Bumps `lastUsedAt` on a session (call when activity happens). */
65
+ touchSession: (sessionId: string) => void
66
+ /** Ensures `agentId` has at least one open session and returns its id. */
67
+ ensureSession: (agentId: string) => AiChatSession
68
+ }
69
+
70
+ const AiChatSessionsContext = React.createContext<AiChatSessionsApi | null>(null)
71
+
72
+ function makeId(): string {
73
+ const g = globalThis as unknown as { crypto?: { randomUUID?: () => string } }
74
+ if (g.crypto && typeof g.crypto.randomUUID === 'function') {
75
+ try {
76
+ return g.crypto.randomUUID()
77
+ } catch {
78
+ /* fall through */
79
+ }
80
+ }
81
+ const rand = () => Math.random().toString(16).slice(2, 10)
82
+ return `${Date.now().toString(16)}-${rand()}-${rand()}`
83
+ }
84
+
85
+ function readPersisted(): AiChatSessionsState {
86
+ if (typeof window === 'undefined') return { sessions: [], activeByAgent: {} }
87
+ try {
88
+ const raw = window.localStorage.getItem(STORAGE_KEY)
89
+ if (!raw) return { sessions: [], activeByAgent: {} }
90
+ const parsed = JSON.parse(raw) as Partial<AiChatSessionsState> | null
91
+ const sessions = Array.isArray(parsed?.sessions)
92
+ ? (parsed!.sessions as unknown[])
93
+ .filter((entry): entry is AiChatSession => {
94
+ if (!entry || typeof entry !== 'object') return false
95
+ const value = entry as Record<string, unknown>
96
+ return (
97
+ typeof value.id === 'string' &&
98
+ typeof value.agentId === 'string' &&
99
+ typeof value.conversationId === 'string' &&
100
+ typeof value.createdAt === 'number' &&
101
+ typeof value.lastUsedAt === 'number' &&
102
+ (value.status === 'open' || value.status === 'closed')
103
+ )
104
+ })
105
+ .map((entry) => {
106
+ const value = entry as unknown as Record<string, unknown>
107
+ const candidate = value.name
108
+ return {
109
+ ...entry,
110
+ name: typeof candidate === 'string' ? candidate : undefined,
111
+ }
112
+ })
113
+ : []
114
+ const activeByAgent =
115
+ parsed?.activeByAgent && typeof parsed.activeByAgent === 'object'
116
+ ? Object.fromEntries(
117
+ Object.entries(parsed.activeByAgent as Record<string, unknown>).filter(
118
+ (entry): entry is [string, string] =>
119
+ typeof entry[0] === 'string' && typeof entry[1] === 'string',
120
+ ),
121
+ )
122
+ : {}
123
+ return { sessions, activeByAgent }
124
+ } catch {
125
+ return { sessions: [], activeByAgent: {} }
126
+ }
127
+ }
128
+
129
+ function writePersisted(state: AiChatSessionsState): void {
130
+ if (typeof window === 'undefined') return
131
+ try {
132
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
133
+ } catch {
134
+ /* quota / privacy mode — drop silently */
135
+ }
136
+ }
137
+
138
+ export function AiChatSessionsProvider({ children }: { children: React.ReactNode }) {
139
+ // Hydrate synchronously via a lazy initializer. The previous "empty
140
+ // state + post-mount load effect" pattern had a window where the
141
+ // persistence effect ran with the empty closure value (because the
142
+ // hydrate effect's queued setState had not committed yet) and clobbered
143
+ // localStorage with `[]` before the loaded state's re-render wrote it
144
+ // back. The lazy initializer puts the loaded state into the very first
145
+ // render, so the persistence effect always sees the real data.
146
+ // `readPersisted` already short-circuits to an empty object on the
147
+ // server (no `window`), so SSR stays consistent with the bare-bones
148
+ // shell — actual session-dependent UI only renders after a user
149
+ // interaction opens a chat surface.
150
+ const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted())
151
+
152
+ React.useEffect(() => {
153
+ writePersisted(state)
154
+ }, [state])
155
+
156
+ const update = React.useCallback(
157
+ (mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {
158
+ setState((prev) => mutator(prev))
159
+ },
160
+ [],
161
+ )
162
+
163
+ const getOpenSessions = React.useCallback(
164
+ (agentId: string) =>
165
+ state.sessions
166
+ .filter((s) => s.agentId === agentId && s.status === 'open')
167
+ .sort((a, b) => a.createdAt - b.createdAt),
168
+ [state.sessions],
169
+ )
170
+
171
+ const getClosedSessions = React.useCallback(
172
+ (agentId: string, limit = HISTORY_LIMIT) =>
173
+ state.sessions
174
+ .filter((s) => s.agentId === agentId && s.status === 'closed')
175
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)
176
+ .slice(0, limit),
177
+ [state.sessions],
178
+ )
179
+
180
+ const getActiveSession = React.useCallback(
181
+ (agentId: string) => {
182
+ const id = state.activeByAgent[agentId]
183
+ if (!id) return null
184
+ const session = state.sessions.find((s) => s.id === id)
185
+ if (!session || session.status !== 'open') return null
186
+ return session
187
+ },
188
+ [state.activeByAgent, state.sessions],
189
+ )
190
+
191
+ const createSession = React.useCallback((agentId: string): AiChatSession => {
192
+ const session: AiChatSession = {
193
+ id: makeId(),
194
+ agentId,
195
+ conversationId: makeId(),
196
+ createdAt: Date.now(),
197
+ lastUsedAt: Date.now(),
198
+ status: 'open',
199
+ }
200
+ update((prev) => ({
201
+ sessions: [...prev.sessions, session],
202
+ activeByAgent: { ...prev.activeByAgent, [agentId]: session.id },
203
+ }))
204
+ return session
205
+ }, [update])
206
+
207
+ const closeSession = React.useCallback(
208
+ (sessionId: string) => {
209
+ update((prev) => {
210
+ const target = prev.sessions.find((s) => s.id === sessionId)
211
+ if (!target) return prev
212
+ const sessions = prev.sessions.map((s) =>
213
+ s.id === sessionId ? { ...s, status: 'closed' as const, lastUsedAt: Date.now() } : s,
214
+ )
215
+ const activeByAgent = { ...prev.activeByAgent }
216
+ if (activeByAgent[target.agentId] === sessionId) {
217
+ // Pick the next open session for this agent (most-recent first).
218
+ const fallback = sessions
219
+ .filter((s) => s.agentId === target.agentId && s.status === 'open')
220
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]
221
+ if (fallback) {
222
+ activeByAgent[target.agentId] = fallback.id
223
+ } else {
224
+ delete activeByAgent[target.agentId]
225
+ }
226
+ }
227
+ return { sessions, activeByAgent }
228
+ })
229
+ },
230
+ [update],
231
+ )
232
+
233
+ const reopenSession = React.useCallback(
234
+ (sessionId: string) => {
235
+ update((prev) => {
236
+ const target = prev.sessions.find((s) => s.id === sessionId)
237
+ if (!target) return prev
238
+ const sessions = prev.sessions.map((s) =>
239
+ s.id === sessionId ? { ...s, status: 'open' as const, lastUsedAt: Date.now() } : s,
240
+ )
241
+ return {
242
+ sessions,
243
+ activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },
244
+ }
245
+ })
246
+ },
247
+ [update],
248
+ )
249
+
250
+ const setActiveSession = React.useCallback(
251
+ (sessionId: string) => {
252
+ update((prev) => {
253
+ const target = prev.sessions.find((s) => s.id === sessionId)
254
+ if (!target || target.status !== 'open') return prev
255
+ const sessions = prev.sessions.map((s) =>
256
+ s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,
257
+ )
258
+ return {
259
+ sessions,
260
+ activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },
261
+ }
262
+ })
263
+ },
264
+ [update],
265
+ )
266
+
267
+ const renameSession = React.useCallback(
268
+ (sessionId: string, name: string) => {
269
+ const trimmed = name.trim()
270
+ update((prev) => ({
271
+ ...prev,
272
+ sessions: prev.sessions.map((s) =>
273
+ s.id === sessionId
274
+ ? { ...s, name: trimmed.length > 0 ? trimmed : undefined }
275
+ : s,
276
+ ),
277
+ }))
278
+ },
279
+ [update],
280
+ )
281
+
282
+ const touchSession = React.useCallback(
283
+ (sessionId: string) => {
284
+ update((prev) => ({
285
+ ...prev,
286
+ sessions: prev.sessions.map((s) =>
287
+ s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,
288
+ ),
289
+ }))
290
+ },
291
+ [update],
292
+ )
293
+
294
+ const ensureSession = React.useCallback(
295
+ (agentId: string): AiChatSession => {
296
+ // Everything happens inside a single functional setState so we always
297
+ // see the latest pending state (not a stale closure). React Strict
298
+ // Mode double-invokes both effects AND setState updaters in dev,
299
+ // which previously caused the auto-bootstrap path to mint two
300
+ // sessions on first chat-pane open. The `mintedSession` closure
301
+ // cache makes the updater itself idempotent across double-invokes
302
+ // (one fresh id per `ensureSession` call, regardless of how many
303
+ // times React replays the updater for purity testing); the
304
+ // functional `prev` lookup handles the case where two queued
305
+ // ensureSession calls resolve back-to-back — the second one sees
306
+ // the first's pending append and short-circuits.
307
+ let resolved: AiChatSession | null = null
308
+ let mintedSession: AiChatSession | null = null
309
+ setState((prev) => {
310
+ const activeId = prev.activeByAgent[agentId]
311
+ if (activeId) {
312
+ const active = prev.sessions.find((s) => s.id === activeId)
313
+ if (active && active.status === 'open') {
314
+ resolved = active
315
+ return prev
316
+ }
317
+ }
318
+ const anyOpen = prev.sessions
319
+ .filter((s) => s.agentId === agentId && s.status === 'open')
320
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]
321
+ if (anyOpen) {
322
+ resolved = anyOpen
323
+ return {
324
+ ...prev,
325
+ activeByAgent: { ...prev.activeByAgent, [agentId]: anyOpen.id },
326
+ }
327
+ }
328
+ if (!mintedSession) {
329
+ mintedSession = {
330
+ id: makeId(),
331
+ agentId,
332
+ conversationId: makeId(),
333
+ createdAt: Date.now(),
334
+ lastUsedAt: Date.now(),
335
+ status: 'open',
336
+ }
337
+ }
338
+ resolved = mintedSession
339
+ return {
340
+ sessions: [...prev.sessions, mintedSession],
341
+ activeByAgent: { ...prev.activeByAgent, [agentId]: mintedSession.id },
342
+ }
343
+ })
344
+ // `setState` returns synchronously after invoking the updater (twice
345
+ // in Strict Mode dev), so `resolved` is guaranteed to be set here.
346
+ return resolved as unknown as AiChatSession
347
+ },
348
+ [],
349
+ )
350
+
351
+ const api = React.useMemo<AiChatSessionsApi>(
352
+ () => ({
353
+ state,
354
+ getOpenSessions,
355
+ getClosedSessions,
356
+ getActiveSession,
357
+ createSession,
358
+ closeSession,
359
+ reopenSession,
360
+ setActiveSession,
361
+ renameSession,
362
+ touchSession,
363
+ ensureSession,
364
+ }),
365
+ [
366
+ state,
367
+ getOpenSessions,
368
+ getClosedSessions,
369
+ getActiveSession,
370
+ createSession,
371
+ closeSession,
372
+ reopenSession,
373
+ setActiveSession,
374
+ renameSession,
375
+ touchSession,
376
+ ensureSession,
377
+ ],
378
+ )
379
+
380
+ return <AiChatSessionsContext.Provider value={api}>{children}</AiChatSessionsContext.Provider>
381
+ }
382
+
383
+ export function useAiChatSessions(): AiChatSessionsApi {
384
+ const ctx = React.useContext(AiChatSessionsContext)
385
+ if (ctx) return ctx
386
+ // Fallback no-op API — keeps consumers safe when the provider is absent
387
+ // (legacy code paths, isolated unit tests). Every method is a no-op so a
388
+ // chat surface without the provider behaves like a single anonymous
389
+ // session (the behavior shipped before the multi-tab work).
390
+ return {
391
+ state: { sessions: [], activeByAgent: {} },
392
+ getOpenSessions: () => [],
393
+ getClosedSessions: () => [],
394
+ getActiveSession: () => null,
395
+ createSession: (agentId) => ({
396
+ id: 'noop',
397
+ agentId,
398
+ conversationId: 'noop',
399
+ createdAt: Date.now(),
400
+ lastUsedAt: Date.now(),
401
+ status: 'open',
402
+ }),
403
+ closeSession: () => {},
404
+ reopenSession: () => {},
405
+ setActiveSession: () => {},
406
+ renameSession: () => {},
407
+ touchSession: () => {},
408
+ ensureSession: (agentId) => ({
409
+ id: 'noop',
410
+ agentId,
411
+ conversationId: 'noop',
412
+ createdAt: Date.now(),
413
+ lastUsedAt: Date.now(),
414
+ status: 'open',
415
+ }),
416
+ }
417
+ }
418
+
419
+ export function defaultSessionLabel(session: AiChatSession): string {
420
+ if (session.name && session.name.trim().length > 0) return session.name.trim()
421
+ const date = new Date(session.createdAt)
422
+ if (Number.isNaN(date.getTime())) return 'Session'
423
+ return date.toLocaleString(undefined, {
424
+ month: 'short',
425
+ day: 'numeric',
426
+ hour: '2-digit',
427
+ minute: '2-digit',
428
+ })
429
+ }