@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,310 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Tab strip rendered above an `<AiChat>` surface (dock panel, dialog sheet).
5
+ *
6
+ * Each tab is a session for the same agent. The strip provides:
7
+ * - tab switching (click)
8
+ * - inline rename (double-click on the active tab title or pencil icon)
9
+ * - close (X on hover)
10
+ * - new session (`+`)
11
+ * - history dropdown (clock icon → recent closed sessions; click reopens)
12
+ *
13
+ * The component is purely UI — state lives in `AiChatSessionsProvider`.
14
+ */
15
+
16
+ import * as React from 'react'
17
+ import { Clock, Pencil, Plus, X } from 'lucide-react'
18
+ import { IconButton } from '../primitives/icon-button'
19
+ import { cn } from '@open-mercato/shared/lib/utils'
20
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
21
+ import {
22
+ defaultSessionLabel,
23
+ useAiChatSessions,
24
+ type AiChatSession,
25
+ } from './AiChatSessions'
26
+
27
+ export interface ChatPaneTabsProps {
28
+ agentId: string
29
+ className?: string
30
+ }
31
+
32
+ export function ChatPaneTabs({ agentId, className }: ChatPaneTabsProps) {
33
+ const t = useT()
34
+ const sessions = useAiChatSessions()
35
+ const open = sessions.getOpenSessions(agentId)
36
+ const closed = sessions.getClosedSessions(agentId)
37
+ const active = sessions.getActiveSession(agentId)
38
+
39
+ const [historyOpen, setHistoryOpen] = React.useState(false)
40
+ const [renamingId, setRenamingId] = React.useState<string | null>(null)
41
+ const [draftName, setDraftName] = React.useState('')
42
+
43
+ const startRename = (session: AiChatSession) => {
44
+ setRenamingId(session.id)
45
+ // Pre-fill with the current name only — never the date fallback. If
46
+ // we pre-filled with the formatted date, blurring without typing
47
+ // would persist that date string as the session name, and creating
48
+ // a new tab a minute later would "leak" the old tab's date label
49
+ // onto every other unnamed tab.
50
+ setDraftName(session.name ?? '')
51
+ }
52
+
53
+ const commitRename = () => {
54
+ if (!renamingId) return
55
+ sessions.renameSession(renamingId, draftName)
56
+ setRenamingId(null)
57
+ }
58
+
59
+ const cancelRename = () => {
60
+ setRenamingId(null)
61
+ setDraftName('')
62
+ }
63
+
64
+ return (
65
+ // Outer wrapper does NOT scroll; only the inner tabs row does. The
66
+ // `+` button and the history dropdown live OUTSIDE that scroll area
67
+ // so the dropdown's absolute positioning isn't clipped by the
68
+ // strip's `overflow-x-auto` (which CSS resolves to `overflow-y:auto`
69
+ // too once one axis is non-`visible`, making any absolutely-
70
+ // positioned child get cut off at the strip's bottom edge).
71
+ <div
72
+ className={cn('flex items-center gap-1 px-2 pt-2 text-sm', className)}
73
+ data-ai-chat-tabs=""
74
+ role="tablist"
75
+ aria-label="Chat sessions"
76
+ >
77
+ <div
78
+ className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
79
+ data-ai-chat-tabs-scroll=""
80
+ >
81
+ {open.length === 0 ? (
82
+ <span className="px-2 py-1 text-xs text-muted-foreground" data-ai-chat-tabs-empty="">
83
+ {t('ai_assistant.chat.tabs.noSessions', 'No sessions')}
84
+ </span>
85
+ ) : (
86
+ open.map((session) => {
87
+ const isActive = active?.id === session.id
88
+ const isRenaming = renamingId === session.id
89
+ const label = defaultSessionLabel(session)
90
+ return (
91
+ <div
92
+ key={session.id}
93
+ role="tab"
94
+ aria-selected={isActive}
95
+ data-ai-chat-tab-id={session.id}
96
+ data-active={isActive ? 'true' : 'false'}
97
+ className={cn(
98
+ 'group flex max-w-[12rem] shrink-0 items-center gap-1 rounded-t-md border-b-2 px-2 py-1',
99
+ isActive
100
+ ? 'border-primary bg-background text-foreground'
101
+ : 'border-transparent text-muted-foreground hover:text-foreground',
102
+ )}
103
+ >
104
+ {isRenaming ? (
105
+ <input
106
+ autoFocus
107
+ type="text"
108
+ value={draftName}
109
+ onChange={(event) => setDraftName(event.target.value)}
110
+ onBlur={commitRename}
111
+ onKeyDown={(event) => {
112
+ if (event.key === 'Enter') {
113
+ event.preventDefault()
114
+ commitRename()
115
+ } else if (event.key === 'Escape') {
116
+ event.preventDefault()
117
+ cancelRename()
118
+ }
119
+ }}
120
+ className="h-6 max-w-[10rem] rounded border border-input bg-background px-1 text-xs outline-none focus:ring-2 focus:ring-ring/40"
121
+ data-ai-chat-tab-rename-input=""
122
+ />
123
+ ) : (
124
+ <button
125
+ type="button"
126
+ onClick={() => sessions.setActiveSession(session.id)}
127
+ onDoubleClick={() => startRename(session)}
128
+ title={label}
129
+ className="truncate text-xs font-medium"
130
+ >
131
+ {label}
132
+ </button>
133
+ )}
134
+ {!isRenaming && isActive ? (
135
+ <IconButton
136
+ type="button"
137
+ variant="ghost"
138
+ size="xs"
139
+ aria-label={t('ai_assistant.chat.tabs.rename', 'Rename')}
140
+ title={t('ai_assistant.chat.tabs.rename', 'Rename')}
141
+ className="opacity-60 hover:opacity-100"
142
+ onClick={() => startRename(session)}
143
+ data-ai-chat-tab-rename=""
144
+ >
145
+ <Pencil className="size-3" />
146
+ </IconButton>
147
+ ) : null}
148
+ <IconButton
149
+ type="button"
150
+ variant="ghost"
151
+ size="xs"
152
+ aria-label={t('ai_assistant.chat.tabs.close', 'Close')}
153
+ title={t('ai_assistant.chat.tabs.close', 'Close')}
154
+ // Always rendered visible (a previous opacity-0 default
155
+ // hid the X on non-hover and made the active-tab close
156
+ // button look unreachable). Closing the very last open
157
+ // tab is fine — `ensureSession` in the chat body's
158
+ // effect immediately mints a fresh empty tab so the user
159
+ // never sees an empty pane.
160
+ className={cn(
161
+ 'transition-opacity',
162
+ isActive ? 'opacity-60 hover:opacity-100' : 'opacity-50 hover:opacity-100',
163
+ )}
164
+ data-active={isActive ? 'true' : 'false'}
165
+ onMouseDown={(event) => {
166
+ // Prevent the parent tab button's blur logic / focus
167
+ // shift from racing the close click on the active tab.
168
+ event.stopPropagation()
169
+ }}
170
+ onClick={(event) => {
171
+ event.preventDefault()
172
+ event.stopPropagation()
173
+ sessions.closeSession(session.id)
174
+ }}
175
+ data-ai-chat-tab-close=""
176
+ >
177
+ <X className="size-3" />
178
+ </IconButton>
179
+ </div>
180
+ )
181
+ })
182
+ )}
183
+ </div>
184
+ <IconButton
185
+ type="button"
186
+ variant="ghost"
187
+ size="sm"
188
+ aria-label={t('ai_assistant.chat.tabs.newSession', 'New session')}
189
+ title={t('ai_assistant.chat.tabs.newSession', 'New session')}
190
+ onClick={() => sessions.createSession(agentId)}
191
+ data-ai-chat-new-session=""
192
+ className="shrink-0"
193
+ >
194
+ <Plus className="size-4" />
195
+ </IconButton>
196
+ <HistoryDropdown
197
+ open={historyOpen}
198
+ onOpenChange={setHistoryOpen}
199
+ closed={closed}
200
+ onPick={(sessionId) => {
201
+ sessions.reopenSession(sessionId)
202
+ setHistoryOpen(false)
203
+ }}
204
+ />
205
+ </div>
206
+ )
207
+ }
208
+
209
+ interface HistoryDropdownProps {
210
+ open: boolean
211
+ onOpenChange: (next: boolean) => void
212
+ closed: AiChatSession[]
213
+ onPick: (sessionId: string) => void
214
+ }
215
+
216
+ /**
217
+ * Plain absolutely-positioned dropdown for the recent-sessions list.
218
+ * Bypasses the Radix Popover primitive on purpose — every chat surface
219
+ * (dock panel, customers/catalog/launcher dialog) creates its own stacking
220
+ * context, and the Radix Portal'd PopoverContent kept ending up either
221
+ * behind the dialog or pushed off the visible area on tall sheets. A
222
+ * direct `position: absolute` child of the trigger button anchors the
223
+ * dropdown to the icon, inherits the surface's stacking context, and is
224
+ * predictable across the dock + every dialog host without z-index hacks.
225
+ */
226
+ function HistoryDropdown({ open, onOpenChange, closed, onPick }: HistoryDropdownProps) {
227
+ const t = useT()
228
+ const containerRef = React.useRef<HTMLDivElement | null>(null)
229
+
230
+ React.useEffect(() => {
231
+ if (!open) return
232
+ const onDown = (event: MouseEvent | TouchEvent) => {
233
+ const root = containerRef.current
234
+ if (!root) return
235
+ if (event.target instanceof Node && root.contains(event.target)) return
236
+ onOpenChange(false)
237
+ }
238
+ const onKey = (event: KeyboardEvent) => {
239
+ if (event.key === 'Escape') onOpenChange(false)
240
+ }
241
+ window.addEventListener('mousedown', onDown)
242
+ window.addEventListener('touchstart', onDown, { passive: true })
243
+ window.addEventListener('keydown', onKey)
244
+ return () => {
245
+ window.removeEventListener('mousedown', onDown)
246
+ window.removeEventListener('touchstart', onDown)
247
+ window.removeEventListener('keydown', onKey)
248
+ }
249
+ }, [open, onOpenChange])
250
+
251
+ return (
252
+ <div ref={containerRef} className="relative shrink-0">
253
+ <IconButton
254
+ type="button"
255
+ variant="ghost"
256
+ size="sm"
257
+ aria-label={t('ai_assistant.chat.tabs.recentSessions', 'Recent sessions')}
258
+ title={t('ai_assistant.chat.tabs.recentSessions', 'Recent sessions')}
259
+ data-ai-chat-history-trigger=""
260
+ aria-expanded={open}
261
+ onClick={() => onOpenChange(!open)}
262
+ >
263
+ <Clock className="size-4" />
264
+ </IconButton>
265
+ {open ? (
266
+ <div
267
+ className="absolute right-0 top-full mt-2 w-72 max-h-[60vh] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
268
+ // Inline z-index so the dropdown sits above any host surface
269
+ // (chat dialog at z-[70], dock panel, modal overlays). Inline
270
+ // beats Tailwind JIT for arbitrary high values.
271
+ style={{ zIndex: 2147483000 }}
272
+ data-ai-chat-history-panel=""
273
+ role="menu"
274
+ >
275
+ <div className="px-3 pt-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
276
+ {t('ai_assistant.chat.tabs.recentSessions', 'Recent sessions')}
277
+ </div>
278
+ {closed.length === 0 ? (
279
+ <div className="px-3 py-3 text-xs text-muted-foreground" data-ai-chat-history-empty="">
280
+ {t('ai_assistant.chat.tabs.noPreviousSessions', 'No previous sessions yet.')}
281
+ </div>
282
+ ) : (
283
+ <div className="flex flex-col gap-0.5">
284
+ {closed.map((session) => (
285
+ <button
286
+ key={session.id}
287
+ type="button"
288
+ role="menuitem"
289
+ onClick={() => onPick(session.id)}
290
+ className="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
291
+ data-ai-chat-history-item={session.id}
292
+ >
293
+ <span className="min-w-0 flex-1 truncate">
294
+ {defaultSessionLabel(session)}
295
+ </span>
296
+ <span className="shrink-0 text-[10px] text-muted-foreground">
297
+ {new Date(session.lastUsedAt).toLocaleDateString(undefined, {
298
+ month: 'short',
299
+ day: 'numeric',
300
+ })}
301
+ </span>
302
+ </button>
303
+ ))}
304
+ </div>
305
+ )}
306
+ </div>
307
+ ) : null}
308
+ </div>
309
+ )
310
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ *
4
+ * Step 5.15 — `<AiChat>` `conversationId` threading.
5
+ *
6
+ * Covers the Phase 3 WS-D contract:
7
+ * 1. Same explicit `conversationId` prop across two mounts yields the same
8
+ * id (the component MUST forward caller-provided ids verbatim).
9
+ * 2. Without the prop, two separate mounts mint two DIFFERENT ids — each
10
+ * mount gets a fresh conversation.
11
+ * 3. The id is included in the `POST /api/ai_assistant/ai/chat` body so
12
+ * downstream `prepareMutation` sees it.
13
+ */
14
+
15
+ // jsdom polyfills (same as AiChat.test.tsx).
16
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
17
+ const nodeUtil = require('node:util') as typeof import('node:util')
18
+ if (typeof globalThis.TextEncoder === 'undefined') {
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ ;(globalThis as any).TextEncoder = nodeUtil.TextEncoder
21
+ }
22
+ if (typeof globalThis.TextDecoder === 'undefined') {
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ ;(globalThis as any).TextDecoder = nodeUtil.TextDecoder as unknown as typeof TextDecoder
25
+ }
26
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
27
+ const nodeStreamWeb = require('node:stream/web') as typeof import('node:stream/web')
28
+ if (typeof (globalThis as unknown as { ReadableStream?: unknown }).ReadableStream === 'undefined') {
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ ;(globalThis as any).ReadableStream = nodeStreamWeb.ReadableStream
31
+ }
32
+
33
+ import * as React from 'react'
34
+ import { act, cleanup, fireEvent, screen } from '@testing-library/react'
35
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
36
+
37
+ jest.mock('@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport', () => ({
38
+ createAiAgentTransport: jest.fn(() => ({
39
+ sendMessages: jest.fn(),
40
+ reconnectToStream: jest.fn(),
41
+ })),
42
+ }))
43
+
44
+ jest.mock('../../backend/utils/api', () => ({
45
+ apiFetch: jest.fn(),
46
+ }))
47
+
48
+ import { apiFetch } from '../../backend/utils/api'
49
+ import { AiChat } from '../AiChat'
50
+
51
+ const dict = {
52
+ 'ai_assistant.chat.composerLabel': 'Message composer',
53
+ 'ai_assistant.chat.composerPlaceholder': 'Message the AI agent...',
54
+ 'ai_assistant.chat.regionLabel': 'AI chat',
55
+ 'ai_assistant.chat.send': 'Send message',
56
+ 'ai_assistant.chat.transcriptLabel': 'Chat transcript',
57
+ }
58
+
59
+ type ResponseLike = {
60
+ ok: boolean
61
+ status: number
62
+ body: ReadableStream<Uint8Array> | null
63
+ clone: () => ResponseLike
64
+ json: () => Promise<unknown>
65
+ text: () => Promise<string>
66
+ }
67
+
68
+ function createStreamingResponse(chunks: string[]): ResponseLike {
69
+ const encoder = new TextEncoder()
70
+ const stream = new ReadableStream<Uint8Array>({
71
+ start(controller) {
72
+ for (const chunk of chunks) {
73
+ controller.enqueue(encoder.encode(chunk))
74
+ }
75
+ controller.close()
76
+ },
77
+ })
78
+ const raw = chunks.join('')
79
+ const self: ResponseLike = {
80
+ ok: true,
81
+ status: 200,
82
+ body: stream,
83
+ clone: () => ({ ...self, body: null }),
84
+ json: async () => ({}),
85
+ text: async () => raw,
86
+ }
87
+ return self
88
+ }
89
+
90
+ describe('<AiChat> conversationId threading (Step 5.15)', () => {
91
+ beforeEach(() => {
92
+ jest.clearAllMocks()
93
+ })
94
+
95
+ afterEach(() => {
96
+ cleanup()
97
+ })
98
+
99
+ it('forwards an explicit conversationId prop verbatim across remounts', () => {
100
+ const explicitId = 'conv-explicit-abc123'
101
+
102
+ const { unmount: unmountA } = renderWithProviders(
103
+ <AiChat agent="customers.account_assistant" conversationId={explicitId} />,
104
+ { dict },
105
+ )
106
+ const regionA = document.querySelector('[data-ai-chat-conversation-id]')
107
+ expect(regionA?.getAttribute('data-ai-chat-conversation-id')).toBe(explicitId)
108
+ unmountA()
109
+
110
+ const { unmount: unmountB } = renderWithProviders(
111
+ <AiChat agent="customers.account_assistant" conversationId={explicitId} />,
112
+ { dict },
113
+ )
114
+ const regionB = document.querySelector('[data-ai-chat-conversation-id]')
115
+ expect(regionB?.getAttribute('data-ai-chat-conversation-id')).toBe(explicitId)
116
+ unmountB()
117
+ })
118
+
119
+ it('mints a fresh conversationId on each mount when the prop is omitted', () => {
120
+ const { unmount: unmountA } = renderWithProviders(
121
+ <AiChat agent="customers.account_assistant" />,
122
+ { dict },
123
+ )
124
+ const regionA = document.querySelector('[data-ai-chat-conversation-id]')
125
+ const idA = regionA?.getAttribute('data-ai-chat-conversation-id') ?? ''
126
+ expect(idA.length).toBeGreaterThan(0)
127
+ unmountA()
128
+
129
+ const { unmount: unmountB } = renderWithProviders(
130
+ <AiChat agent="customers.account_assistant" />,
131
+ { dict },
132
+ )
133
+ const regionB = document.querySelector('[data-ai-chat-conversation-id]')
134
+ const idB = regionB?.getAttribute('data-ai-chat-conversation-id') ?? ''
135
+ expect(idB.length).toBeGreaterThan(0)
136
+ expect(idB).not.toBe(idA)
137
+ unmountB()
138
+ })
139
+
140
+ it('includes conversationId in the POST body forwarded to the dispatcher', async () => {
141
+ const fetchMock = apiFetch as unknown as jest.Mock
142
+ fetchMock.mockResolvedValueOnce(createStreamingResponse(['ok']))
143
+
144
+ renderWithProviders(
145
+ <AiChat agent="customers.account_assistant" conversationId="conv-body-xyz" />,
146
+ { dict },
147
+ )
148
+
149
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
150
+ fireEvent.change(textarea, { target: { value: 'hi' } })
151
+ await act(async () => {
152
+ fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
153
+ })
154
+
155
+ expect(fetchMock).toHaveBeenCalledTimes(1)
156
+ const [, init] = fetchMock.mock.calls[0]
157
+ const parsedBody = JSON.parse(init.body as string)
158
+ expect(parsedBody.conversationId).toBe('conv-body-xyz')
159
+ })
160
+ })
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ // jsdom does not ship TextEncoder/TextDecoder/ReadableStream globals — polyfill
6
+ // before any consumer module imports them.
7
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
8
+ const nodeUtil = require('node:util') as typeof import('node:util')
9
+ if (typeof globalThis.TextEncoder === 'undefined') {
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ ;(globalThis as any).TextEncoder = nodeUtil.TextEncoder
12
+ }
13
+ if (typeof globalThis.TextDecoder === 'undefined') {
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ ;(globalThis as any).TextDecoder = nodeUtil.TextDecoder as unknown as typeof TextDecoder
16
+ }
17
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
18
+ const nodeStreamWeb = require('node:stream/web') as typeof import('node:stream/web')
19
+ if (typeof (globalThis as unknown as { ReadableStream?: unknown }).ReadableStream === 'undefined') {
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ ;(globalThis as any).ReadableStream = nodeStreamWeb.ReadableStream
22
+ }
23
+
24
+ import * as React from 'react'
25
+ import { fireEvent, screen } from '@testing-library/react'
26
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
27
+
28
+ jest.mock('@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport', () => ({
29
+ createAiAgentTransport: jest.fn(() => ({
30
+ sendMessages: jest.fn(),
31
+ reconnectToStream: jest.fn(),
32
+ })),
33
+ }))
34
+
35
+ jest.mock('../../backend/utils/api', () => ({
36
+ apiFetch: jest.fn(),
37
+ }))
38
+
39
+ import { AiChat } from '../AiChat'
40
+
41
+ const dict = {
42
+ 'ai_assistant.chat.composerLabel': 'Message composer',
43
+ 'ai_assistant.chat.composerPlaceholder': 'Message the AI agent...',
44
+ 'ai_assistant.chat.debug.panelTitle': 'Debug panel',
45
+ 'ai_assistant.chat.debug.toolsSection': 'Resolved tools',
46
+ 'ai_assistant.chat.debug.toolsEmpty': 'No tools resolved for this agent yet.',
47
+ 'ai_assistant.chat.debug.promptSection': 'Prompt sections',
48
+ 'ai_assistant.chat.debug.promptEmpty': 'No prompt sections resolved for this agent.',
49
+ 'ai_assistant.chat.debug.lastRequestSection': 'Last request',
50
+ 'ai_assistant.chat.debug.lastRequestEmpty': 'No request has been sent yet.',
51
+ 'ai_assistant.chat.debug.lastResponseSection': 'Last response',
52
+ 'ai_assistant.chat.debug.lastResponseEmpty': 'No response received yet.',
53
+ 'ai_assistant.chat.debug.statusLabel': 'Status:',
54
+ 'ai_assistant.chat.debug.toolMutation': 'mutation',
55
+ 'ai_assistant.chat.debug.toolRead': 'read',
56
+ 'ai_assistant.chat.debug.toolNoFeatures': 'no required features',
57
+ 'ai_assistant.chat.debug.promptDefault': 'default',
58
+ 'ai_assistant.chat.debug.promptPlaceholder': 'placeholder',
59
+ 'ai_assistant.chat.debug.promptOverride': 'override',
60
+ 'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
61
+ 'ai_assistant.chat.regionLabel': 'AI chat',
62
+ 'ai_assistant.chat.send': 'Send message',
63
+ 'ai_assistant.chat.shortcutHint': 'Press Cmd/Ctrl+Enter to send, Escape to cancel.',
64
+ 'ai_assistant.chat.thinking': 'Thinking...',
65
+ 'ai_assistant.chat.transcriptLabel': 'Chat transcript',
66
+ 'ai_assistant.chat.uiPartPending': 'Pending UI part:',
67
+ 'ai_assistant.chat.userRoleLabel': 'You',
68
+ 'ai_assistant.chat.assistantRoleLabel': 'Assistant',
69
+ 'ai_assistant.chat.emptyTranscript':
70
+ 'No messages yet. Ask the agent anything to get started.',
71
+ 'ai_assistant.chat.cancel': 'Cancel streaming response',
72
+ }
73
+
74
+ describe('<AiChat> debug panel (Step 4.6)', () => {
75
+ it('renders all four debug sections when debug=true', () => {
76
+ renderWithProviders(
77
+ <AiChat
78
+ agent="customers.assistant"
79
+ debug
80
+ debugTools={[
81
+ {
82
+ name: 'customers.list_people',
83
+ displayName: 'List people',
84
+ isMutation: false,
85
+ requiredFeatures: ['customers.view'],
86
+ },
87
+ { name: 'customers.update_person', isMutation: true },
88
+ ]}
89
+ debugPromptSections={[
90
+ { id: 'role', source: 'default', text: 'You are an assistant.' },
91
+ { id: 'scope', source: 'placeholder' },
92
+ ]}
93
+ />,
94
+ { dict },
95
+ )
96
+ expect(screen.getByText('Debug panel')).toBeInTheDocument()
97
+ // Tools section
98
+ const toolsSection = screen.getByText(/Resolved tools/i)
99
+ expect(toolsSection).toBeInTheDocument()
100
+ // Both tools render
101
+ expect(screen.getByText('customers.list_people')).toBeInTheDocument()
102
+ expect(screen.getByText('customers.update_person')).toBeInTheDocument()
103
+ // Prompt sections render
104
+ expect(screen.getByText(/Prompt sections/i)).toBeInTheDocument()
105
+ expect(screen.getByText('role')).toBeInTheDocument()
106
+ expect(screen.getByText('scope')).toBeInTheDocument()
107
+ // Last request / response sections render
108
+ expect(screen.getByText('Last request')).toBeInTheDocument()
109
+ expect(screen.getByText('Last response')).toBeInTheDocument()
110
+ // Empty-state copy for request + response until the user sends something.
111
+ expect(screen.getByText('No request has been sent yet.')).toBeInTheDocument()
112
+ expect(screen.getByText('No response received yet.')).toBeInTheDocument()
113
+ })
114
+
115
+ it('does not render the debug panel when debug is falsy', () => {
116
+ renderWithProviders(
117
+ <AiChat agent="customers.assistant" />,
118
+ { dict },
119
+ )
120
+ expect(screen.queryByText('Debug panel')).not.toBeInTheDocument()
121
+ })
122
+
123
+ it('collapses sections via the native <details> toggle', () => {
124
+ renderWithProviders(
125
+ <AiChat agent="customers.assistant" debug />,
126
+ { dict },
127
+ )
128
+ const toolsDetails = document.querySelector(
129
+ '[data-ai-chat-debug-section="tools"]',
130
+ ) as HTMLDetailsElement | null
131
+ expect(toolsDetails).not.toBeNull()
132
+ // Opens by default (we pass `open` on the tools section).
133
+ expect(toolsDetails!.open).toBe(true)
134
+ fireEvent.click(toolsDetails!.querySelector('summary')!)
135
+ // jsdom does not automatically toggle <details> on click — emulate state
136
+ // by assigning `open` ourselves; the assertion is simply that the <details>
137
+ // element is present and addressable.
138
+ toolsDetails!.open = false
139
+ expect(toolsDetails!.open).toBe(false)
140
+ })
141
+
142
+ it('renders the empty-state copy when no tools/sections are provided', () => {
143
+ renderWithProviders(
144
+ <AiChat agent="customers.assistant" debug debugTools={[]} debugPromptSections={[]} />,
145
+ { dict },
146
+ )
147
+ expect(screen.getByText('No tools resolved for this agent yet.')).toBeInTheDocument()
148
+ expect(
149
+ screen.getByText('No prompt sections resolved for this agent.'),
150
+ ).toBeInTheDocument()
151
+ })
152
+ })