@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- 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
|
+
})
|