@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,1483 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ Bot,
6
+ Check,
7
+ ChevronDown,
8
+ ChevronRight,
9
+ Copy,
10
+ Lightbulb,
11
+ Loader2,
12
+ Paperclip,
13
+ Plus,
14
+ Send,
15
+ Square,
16
+ User,
17
+ Wrench,
18
+ X,
19
+ } from 'lucide-react'
20
+ import ReactMarkdown from 'react-markdown'
21
+ import remarkGfm from 'remark-gfm'
22
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
23
+ import { cn } from '@open-mercato/shared/lib/utils'
24
+ import { Alert, AlertDescription, AlertTitle } from '../primitives/alert'
25
+ import { Button } from '../primitives/button'
26
+ import { IconButton } from '../primitives/icon-button'
27
+ import { Label } from '../primitives/label'
28
+ import { Textarea } from '../primitives/textarea'
29
+ import { parseAiContentSegments } from './AiMessageContent'
30
+ import { RecordCard } from './records/RecordCard'
31
+ import {
32
+ defaultAiUiPartRegistry,
33
+ isReservedAiUiPartId,
34
+ type AiUiPartComponentId,
35
+ type AiUiPartRegistry,
36
+ } from './ui-part-registry'
37
+ import {
38
+ useAiChat,
39
+ type AiChatMessage,
40
+ type AiChatMessageFile,
41
+ type AiChatMessageUiPart,
42
+ type AiChatToolCallSnapshot,
43
+ } from './useAiChat'
44
+ import { useAiChatUpload } from './useAiChatUpload'
45
+ import { useAiShortcuts } from './useAiShortcuts'
46
+
47
+ // Cap inline previews so we do not blow past localStorage quota (~5MB on most
48
+ // browsers). Images larger than this still upload + send to the LLM as inline
49
+ // base64 server-side; only the in-chat preview is dropped on reload.
50
+ const PREVIEW_DATA_URL_MAX_BYTES = 2 * 1024 * 1024
51
+
52
+ async function readFileAsDataUrl(file: File): Promise<string | undefined> {
53
+ if (!file.type.startsWith('image/')) return undefined
54
+ if (file.size > PREVIEW_DATA_URL_MAX_BYTES) return undefined
55
+ return new Promise<string | undefined>((resolve) => {
56
+ const reader = new FileReader()
57
+ reader.onload = () =>
58
+ resolve(typeof reader.result === 'string' ? reader.result : undefined)
59
+ reader.onerror = () => resolve(undefined)
60
+ try {
61
+ reader.readAsDataURL(file)
62
+ } catch {
63
+ resolve(undefined)
64
+ }
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Optional resolved-tool snapshot the host can feed into the debug panel.
70
+ * Step 4.6 wires this from the `GET /api/ai_assistant/ai/agents` response
71
+ * (`tools[]`). Step 5.3+ will replace the manual wiring with a streamed
72
+ * `debug` part once the dispatcher emits one.
73
+ */
74
+ export interface AiChatDebugTool {
75
+ name: string
76
+ displayName?: string
77
+ isMutation?: boolean
78
+ requiredFeatures?: string[]
79
+ }
80
+
81
+ /**
82
+ * Resolved prompt-section snapshot for the debug panel. Until Phase 3 Step
83
+ * 5.3 lands structured `PromptTemplate.sections`, hosts synthesise this
84
+ * from the agent's `systemPrompt` + additive overrides.
85
+ */
86
+ export interface AiChatDebugPromptSection {
87
+ id: string
88
+ source?: 'default' | 'override' | 'placeholder'
89
+ text?: string
90
+ }
91
+
92
+ /** Quick-action suggestion shown in the welcome state. */
93
+ export interface AiChatSuggestion {
94
+ label: string
95
+ prompt: string
96
+ icon?: React.ReactNode
97
+ }
98
+
99
+ /** Context item displayed as a chip/pill in the chat header. */
100
+ export interface AiChatContextItem {
101
+ label: string
102
+ detail?: string
103
+ }
104
+
105
+ export interface AiChatProps {
106
+ agent: string
107
+ apiPath?: string
108
+ pageContext?: Record<string, unknown>
109
+ attachmentIds?: string[]
110
+ initialMessages?: Array<{ role: 'user' | 'assistant'; content: string }>
111
+ debug?: boolean
112
+ className?: string
113
+ placeholder?: string
114
+ onMutationRequested?: (pendingActionId: string) => void
115
+ onError?: (err: { code?: string; message: string }) => void
116
+ /**
117
+ * Optional stable conversation id. Forwarded verbatim to
118
+ * `POST /api/ai_assistant/ai/chat` request bodies and reused across
119
+ * turns so the Step 5.6 `prepareMutation` idempotency hash stays
120
+ * stable across retries within the same chat. When omitted, the hook
121
+ * mints a fresh random id once on mount — remounting the component
122
+ * resets the conversation.
123
+ */
124
+ conversationId?: string
125
+ /**
126
+ * Optional UI-part registry. Defaults to the module-global
127
+ * {@link defaultAiUiPartRegistry}. Pass a scoped registry from
128
+ * {@link createAiUiPartRegistry} when embedding multiple `<AiChat>`
129
+ * instances that should not share registrations (playground, tests).
130
+ */
131
+ registry?: AiUiPartRegistry
132
+ /**
133
+ * Optional list of server-emitted UI parts to render inside the chat
134
+ * transcript. The registry resolves each part via `componentId`. Phase 3
135
+ * will populate this from the streamed dispatcher response; Phase 2 WS-A
136
+ * leaves the wiring exposed so hosts can preview the registry path.
137
+ */
138
+ uiParts?: Array<{
139
+ componentId: AiUiPartComponentId
140
+ payload?: unknown
141
+ pendingActionId?: string
142
+ }>
143
+ /**
144
+ * Optional resolved-tool map for the debug panel. Ignored when
145
+ * `debug` is falsy.
146
+ */
147
+ debugTools?: AiChatDebugTool[]
148
+ /**
149
+ * Optional resolved prompt sections for the debug panel. Ignored when
150
+ * `debug` is falsy.
151
+ */
152
+ debugPromptSections?: AiChatDebugPromptSection[]
153
+ /** Suggested prompts shown in the empty / welcome state. */
154
+ suggestions?: AiChatSuggestion[]
155
+ /** Context items shown as pills above the transcript (e.g. selected products). */
156
+ contextItems?: AiChatContextItem[]
157
+ /** Welcome heading shown when there are no messages yet. */
158
+ welcomeTitle?: string
159
+ /** Welcome description shown below the heading. */
160
+ welcomeDescription?: string
161
+ }
162
+
163
+ interface ServerEmittedUiPartRef {
164
+ componentId: AiUiPartComponentId
165
+ payload?: unknown
166
+ pendingActionId?: string
167
+ }
168
+
169
+ function mapErrorCodeToVariant(
170
+ code: string | undefined,
171
+ ): 'destructive' | 'warning' {
172
+ if (!code) return 'destructive'
173
+ // Policy denies that describe a filtered tool or attachment surface a
174
+ // warning alert; caller can still continue. Hard denials (agent_unknown,
175
+ // agent_features_denied, unauthenticated, execution_mode_not_supported,
176
+ // mutation_blocked_by_*, validation_error) surface destructive alerts.
177
+ const warningCodes = new Set<string>([
178
+ 'tool_not_whitelisted',
179
+ 'tool_features_denied',
180
+ 'attachment_type_not_accepted',
181
+ ])
182
+ return warningCodes.has(code) ? 'warning' : 'destructive'
183
+ }
184
+
185
+ const MARKDOWN_TYPOGRAPHY_CLASS = cn(
186
+ '[&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
187
+ '[&_ul]:my-2 [&_ol]:my-2 [&_ul]:ml-4 [&_ol]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal',
188
+ '[&_li]:my-0.5',
189
+ '[&_h1]:mt-3 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold',
190
+ '[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold',
191
+ '[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold',
192
+ '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
193
+ '[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
194
+ '[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-3',
195
+ '[&_pre_code]:bg-transparent [&_pre_code]:p-0',
196
+ '[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
197
+ '[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs',
198
+ '[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium',
199
+ '[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
200
+ )
201
+
202
+ const MARKDOWN_COMPONENTS = {
203
+ a: ({ node, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) => (
204
+ <a {...props} target="_blank" rel="noreferrer" />
205
+ ),
206
+ }
207
+
208
+ function MarkdownChunk({ text }: { text: string }) {
209
+ if (!text.trim()) return null
210
+ return (
211
+ <div className={cn('text-sm', MARKDOWN_TYPOGRAPHY_CLASS)}>
212
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
213
+ {text}
214
+ </ReactMarkdown>
215
+ </div>
216
+ )
217
+ }
218
+
219
+ function MessageContent({ content, isAssistant }: { content: string; isAssistant: boolean }) {
220
+ if (!isAssistant) {
221
+ return <div className="whitespace-pre-wrap text-sm">{content}</div>
222
+ }
223
+ if (!content) {
224
+ return null
225
+ }
226
+ const segments = parseAiContentSegments(content)
227
+ if (segments.length === 0) {
228
+ return null
229
+ }
230
+ return (
231
+ <div className="space-y-1" data-ai-message-content="">
232
+ {segments.map((segment, index) => {
233
+ if (segment.kind === 'record-card') {
234
+ return <RecordCard key={`card-${index}`} data={segment.payload} />
235
+ }
236
+ if (segment.kind === 'invalid-card') {
237
+ return (
238
+ <pre
239
+ key={`raw-${index}`}
240
+ className="my-2 max-h-60 overflow-auto rounded-md border border-dashed border-border bg-muted p-2 text-xs"
241
+ data-ai-record-card-invalid={segment.info}
242
+ >
243
+ {segment.raw}
244
+ </pre>
245
+ )
246
+ }
247
+ return <MarkdownChunk key={`md-${index}`} text={segment.text} />
248
+ })}
249
+ </div>
250
+ )
251
+ }
252
+
253
+ function safeStringify(value: unknown): string {
254
+ if (value === null || value === undefined) return ''
255
+ if (typeof value === 'string') return value
256
+ try {
257
+ return JSON.stringify(value, null, 2)
258
+ } catch {
259
+ return String(value)
260
+ }
261
+ }
262
+
263
+ function ToolCallList({ toolCalls }: { toolCalls: AiChatToolCallSnapshot[] }) {
264
+ const t = useT()
265
+ const [openId, setOpenId] = React.useState<string | null>(null)
266
+ if (!toolCalls || toolCalls.length === 0) return null
267
+ return (
268
+ <div className="space-y-1" data-ai-chat-tool-calls="">
269
+ {toolCalls.map((call) => {
270
+ const isOpen = openId === call.id
271
+ const isError = call.state === 'error'
272
+ const isPending = call.state === 'pending'
273
+ const isComplete = call.state === 'complete'
274
+ const statusLabel = isError
275
+ ? t('ai_assistant.chat.toolError', 'failed')
276
+ : isPending
277
+ ? t('ai_assistant.chat.toolRunning', 'running…')
278
+ : t('ai_assistant.chat.toolDone', 'done')
279
+ return (
280
+ <div
281
+ key={call.id}
282
+ className={cn(
283
+ 'rounded-md border border-border bg-muted/30',
284
+ isError ? 'border-destructive/40 bg-destructive/5' : '',
285
+ )}
286
+ data-ai-chat-tool-call={call.toolName}
287
+ data-ai-chat-tool-state={call.state}
288
+ >
289
+ <button
290
+ type="button"
291
+ onClick={() => setOpenId(isOpen ? null : call.id)}
292
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs font-medium hover:bg-muted/60"
293
+ >
294
+ {isOpen ? (
295
+ <ChevronDown className="size-3.5 text-muted-foreground" aria-hidden />
296
+ ) : (
297
+ <ChevronRight className="size-3.5 text-muted-foreground" aria-hidden />
298
+ )}
299
+ {isPending ? (
300
+ <Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-hidden />
301
+ ) : (
302
+ <Wrench
303
+ className={cn(
304
+ 'size-3.5',
305
+ isError ? 'text-destructive' : 'text-muted-foreground',
306
+ )}
307
+ aria-hidden
308
+ />
309
+ )}
310
+ <span className="font-mono">{call.toolName}</span>
311
+ <span
312
+ className={cn(
313
+ 'ml-auto text-[10px] uppercase tracking-wide',
314
+ isError
315
+ ? 'text-destructive'
316
+ : isComplete
317
+ ? 'text-status-success-text'
318
+ : 'text-muted-foreground',
319
+ )}
320
+ >
321
+ {statusLabel}
322
+ </span>
323
+ </button>
324
+ {isOpen ? (
325
+ <div className="space-y-1 border-t border-border/60 px-2 py-1.5 text-xs">
326
+ {call.input !== undefined ? (
327
+ <div>
328
+ <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
329
+ {t('ai_assistant.chat.toolInput', 'Input')}
330
+ </div>
331
+ <pre className="mt-0.5 max-h-32 overflow-auto rounded bg-background p-1.5 font-mono text-[11px]">
332
+ {safeStringify(call.input)}
333
+ </pre>
334
+ </div>
335
+ ) : null}
336
+ {call.output !== undefined && !isError ? (
337
+ <div>
338
+ <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
339
+ {t('ai_assistant.chat.toolOutput', 'Output')}
340
+ </div>
341
+ <pre className="mt-0.5 max-h-32 overflow-auto rounded bg-background p-1.5 font-mono text-[11px]">
342
+ {safeStringify(call.output)}
343
+ </pre>
344
+ </div>
345
+ ) : null}
346
+ {call.errorMessage ? (
347
+ <div className="text-destructive">{call.errorMessage}</div>
348
+ ) : null}
349
+ </div>
350
+ ) : null}
351
+ </div>
352
+ )
353
+ })}
354
+ </div>
355
+ )
356
+ }
357
+
358
+ function ReasoningPanel({ text, streaming }: { text: string; streaming: boolean }) {
359
+ const t = useT()
360
+ const [open, setOpen] = React.useState(false)
361
+ if (!text) return null
362
+ return (
363
+ <div
364
+ className="rounded-md border border-border bg-muted/30"
365
+ data-ai-chat-reasoning={streaming ? 'streaming' : 'complete'}
366
+ >
367
+ <button
368
+ type="button"
369
+ onClick={() => setOpen((o) => !o)}
370
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs font-medium hover:bg-muted/60"
371
+ >
372
+ {open ? (
373
+ <ChevronDown className="size-3.5 text-muted-foreground" aria-hidden />
374
+ ) : (
375
+ <ChevronRight className="size-3.5 text-muted-foreground" aria-hidden />
376
+ )}
377
+ <Lightbulb className="size-3.5 text-muted-foreground" aria-hidden />
378
+ <span>{t('ai_assistant.chat.reasoning', 'Reasoning')}</span>
379
+ {streaming ? (
380
+ <Loader2
381
+ className="ml-1 size-3 animate-spin text-muted-foreground"
382
+ aria-hidden
383
+ />
384
+ ) : null}
385
+ </button>
386
+ {open ? (
387
+ <pre className="max-h-48 overflow-auto whitespace-pre-wrap border-t border-border/60 px-2 py-1.5 text-xs text-muted-foreground">
388
+ {text}
389
+ </pre>
390
+ ) : null}
391
+ </div>
392
+ )
393
+ }
394
+
395
+ function MessageRow({
396
+ message,
397
+ registry,
398
+ onMutationRequested,
399
+ }: {
400
+ message: AiChatMessage
401
+ registry?: AiUiPartRegistry
402
+ onMutationRequested?: (pendingActionId: string) => void
403
+ }) {
404
+ const t = useT()
405
+ const isAssistant = message.role === 'assistant'
406
+ const label = isAssistant
407
+ ? t('ai_assistant.chat.assistantRoleLabel', 'Assistant')
408
+ : t('ai_assistant.chat.userRoleLabel', 'You')
409
+ const Icon = isAssistant ? Bot : User
410
+ const [copied, setCopied] = React.useState(false)
411
+ const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
412
+
413
+ React.useEffect(() => {
414
+ return () => {
415
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
416
+ }
417
+ }, [])
418
+
419
+ const handleCopy = React.useCallback(async () => {
420
+ const text = message.content
421
+ if (!text) return
422
+ try {
423
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
424
+ await navigator.clipboard.writeText(text)
425
+ } else {
426
+ const textarea = document.createElement('textarea')
427
+ textarea.value = text
428
+ textarea.setAttribute('readonly', '')
429
+ textarea.style.position = 'absolute'
430
+ textarea.style.left = '-9999px'
431
+ document.body.appendChild(textarea)
432
+ textarea.select()
433
+ document.execCommand('copy')
434
+ document.body.removeChild(textarea)
435
+ }
436
+ setCopied(true)
437
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
438
+ copyTimerRef.current = setTimeout(() => setCopied(false), 1500)
439
+ } catch {
440
+ // Clipboard API blocked (no permission, http context) — silently fail.
441
+ }
442
+ }, [message.content])
443
+
444
+ return (
445
+ <div
446
+ className={cn(
447
+ 'group/message flex gap-3 px-3 py-2',
448
+ isAssistant ? 'bg-muted/40 rounded-md' : '',
449
+ )}
450
+ data-role={message.role}
451
+ >
452
+ <div
453
+ className={cn(
454
+ 'flex size-6 shrink-0 items-center justify-center rounded-full',
455
+ isAssistant
456
+ ? 'bg-primary/10 text-primary'
457
+ : 'bg-secondary text-secondary-foreground',
458
+ )}
459
+ aria-hidden
460
+ >
461
+ <Icon className="size-4" />
462
+ </div>
463
+ <div className="flex-1 space-y-1">
464
+ <div className="flex items-center justify-between gap-2">
465
+ <div className="text-xs font-medium text-muted-foreground">{label}</div>
466
+ {message.content ? (
467
+ <IconButton
468
+ type="button"
469
+ variant="ghost"
470
+ size="sm"
471
+ onClick={handleCopy}
472
+ aria-label={
473
+ copied
474
+ ? t('ai_assistant.chat.copied', 'Copied')
475
+ : t('ai_assistant.chat.copyMessage', 'Copy message')
476
+ }
477
+ data-ai-chat-copy-button=""
478
+ className="opacity-0 transition-opacity group-hover/message:opacity-100 focus-visible:opacity-100"
479
+ >
480
+ {copied ? (
481
+ <Check className="size-3.5 text-status-success-icon" aria-hidden />
482
+ ) : (
483
+ <Copy className="size-3.5" aria-hidden />
484
+ )}
485
+ </IconButton>
486
+ ) : null}
487
+ </div>
488
+ {message.files && message.files.length > 0 ? (
489
+ <div className="flex flex-wrap gap-2 py-1">
490
+ {message.files.map((file, i) =>
491
+ file.previewUrl ? (
492
+ <img
493
+ key={i}
494
+ src={file.previewUrl}
495
+ alt={file.name}
496
+ className="max-h-32 max-w-[200px] rounded-md border border-border object-cover"
497
+ />
498
+ ) : (
499
+ <span
500
+ key={i}
501
+ className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs"
502
+ >
503
+ <Paperclip className="size-3" aria-hidden />
504
+ {file.name}
505
+ </span>
506
+ ),
507
+ )}
508
+ </div>
509
+ ) : null}
510
+ {isAssistant && message.reasoning ? (
511
+ <ReasoningPanel
512
+ text={message.reasoning}
513
+ streaming={message.reasoningStreaming === true}
514
+ />
515
+ ) : null}
516
+ {isAssistant && message.toolCalls && message.toolCalls.length > 0 ? (
517
+ <ToolCallList toolCalls={message.toolCalls} />
518
+ ) : null}
519
+ <MessageContent content={message.content} isAssistant={isAssistant} />
520
+ {isAssistant && registry && message.uiParts && message.uiParts.length > 0 ? (
521
+ <MessageUiParts
522
+ parts={message.uiParts}
523
+ registry={registry}
524
+ onMutationRequested={onMutationRequested}
525
+ />
526
+ ) : null}
527
+ </div>
528
+ </div>
529
+ )
530
+ }
531
+
532
+ function MessageUiParts({
533
+ parts,
534
+ registry,
535
+ onMutationRequested,
536
+ }: {
537
+ parts: AiChatMessageUiPart[]
538
+ registry: AiUiPartRegistry
539
+ onMutationRequested?: (pendingActionId: string) => void
540
+ }) {
541
+ return (
542
+ <div className="mt-2 flex flex-col gap-2" data-ai-message-ui-parts="">
543
+ {parts.map((part) => (
544
+ <AiUiPartRenderer
545
+ key={part.key}
546
+ part={{
547
+ componentId: part.componentId as AiUiPartComponentId,
548
+ payload: part.payload,
549
+ pendingActionId: part.pendingActionId,
550
+ }}
551
+ registry={registry}
552
+ onMutationRequested={onMutationRequested}
553
+ />
554
+ ))}
555
+ </div>
556
+ )
557
+ }
558
+
559
+ function UnknownUiPartPlaceholder({ componentId }: { componentId: AiUiPartComponentId }) {
560
+ const t = useT()
561
+ return (
562
+ <div
563
+ className="inline-flex items-center gap-2 rounded-full border border-dashed border-border bg-muted px-3 py-1 text-xs text-muted-foreground"
564
+ data-ai-ui-part-placeholder={componentId}
565
+ >
566
+ <span>
567
+ {t('ai_assistant.chat.uiPartPending', 'Pending UI part:')} {componentId}
568
+ </span>
569
+ </div>
570
+ )
571
+ }
572
+
573
+ function AiUiPartRenderer({
574
+ part,
575
+ registry,
576
+ onMutationRequested,
577
+ }: {
578
+ part: ServerEmittedUiPartRef
579
+ registry: AiUiPartRegistry
580
+ onMutationRequested?: (pendingActionId: string) => void
581
+ }) {
582
+ const Component = registry.resolve(part.componentId)
583
+ const isReserved = isReservedAiUiPartId(part.componentId)
584
+ React.useEffect(() => {
585
+ if (Component) return
586
+ if (isReserved) return
587
+ try {
588
+ // eslint-disable-next-line no-console
589
+ console.warn(
590
+ `[AiChat] No component registered for UI part "${part.componentId}".`,
591
+ )
592
+ } catch {
593
+ // noop
594
+ }
595
+ }, [Component, part.componentId, isReserved])
596
+ React.useEffect(() => {
597
+ if (part.pendingActionId) {
598
+ onMutationRequested?.(part.pendingActionId)
599
+ }
600
+ }, [part.pendingActionId, onMutationRequested])
601
+ if (!Component) {
602
+ return <UnknownUiPartPlaceholder componentId={part.componentId} />
603
+ }
604
+ return (
605
+ <Component
606
+ componentId={part.componentId}
607
+ payload={part.payload}
608
+ pendingActionId={part.pendingActionId}
609
+ />
610
+ )
611
+ }
612
+
613
+ function WelcomeState({
614
+ title,
615
+ description,
616
+ suggestions,
617
+ onSuggestionClick,
618
+ }: {
619
+ title?: string
620
+ description?: string
621
+ suggestions?: AiChatSuggestion[]
622
+ onSuggestionClick: (prompt: string) => void
623
+ }) {
624
+ const t = useT()
625
+ const heading = title ?? t('ai_assistant.chat.welcomeTitle', 'How can I help?')
626
+ const desc =
627
+ description ??
628
+ t(
629
+ 'ai_assistant.chat.welcomeDescription',
630
+ 'Ask me anything about your data. Here are some things I can do:',
631
+ )
632
+ return (
633
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-8">
634
+ <div
635
+ className="flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary"
636
+ aria-hidden
637
+ >
638
+ <Bot className="size-6" />
639
+ </div>
640
+ <div className="space-y-1 text-center">
641
+ <h3 className="text-sm font-semibold">{heading}</h3>
642
+ <p className="text-xs text-muted-foreground">{desc}</p>
643
+ </div>
644
+ {suggestions && suggestions.length > 0 ? (
645
+ <div className="flex w-full max-w-md flex-col gap-2" data-ai-chat-suggestions="">
646
+ {suggestions.map((suggestion, index) => (
647
+ <button
648
+ key={index}
649
+ type="button"
650
+ className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
651
+ onClick={() => onSuggestionClick(suggestion.prompt)}
652
+ data-ai-chat-suggestion={index}
653
+ >
654
+ {suggestion.icon ? (
655
+ <span className="shrink-0 text-muted-foreground" aria-hidden>
656
+ {suggestion.icon}
657
+ </span>
658
+ ) : null}
659
+ <span>{suggestion.label}</span>
660
+ </button>
661
+ ))}
662
+ </div>
663
+ ) : null}
664
+ </div>
665
+ )
666
+ }
667
+
668
+ function ContextItemsPill({ items }: { items: AiChatContextItem[] }) {
669
+ if (items.length === 0) return null
670
+ return (
671
+ <div
672
+ className="flex flex-wrap gap-1.5 border-b border-border px-3 py-2"
673
+ data-ai-chat-context-items=""
674
+ >
675
+ {items.map((item, index) => (
676
+ <span
677
+ key={index}
678
+ className="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
679
+ data-ai-chat-context-item={index}
680
+ title={item.detail}
681
+ >
682
+ {item.label}
683
+ </span>
684
+ ))}
685
+ </div>
686
+ )
687
+ }
688
+
689
+ /**
690
+ * Embeddable AI chat component. Binds to the dispatcher route
691
+ * `POST /api/ai_assistant/ai/chat?agent=<module>.<agent>` via
692
+ * {@link createAiAgentTransport}. Phase 2 WS-A deliverable (Step 4.1).
693
+ *
694
+ * - Keyboard: `Enter` submits; `Shift+Enter` inserts a newline; `Escape`
695
+ * aborts streaming (or blurs the composer when idle).
696
+ * - Error envelopes from the dispatcher surface as `Alert` + `onError`.
697
+ * - UI parts render via the client-side registry; unknown parts render a
698
+ * neutral placeholder chip so mutation-card slots reserved for Phase 3
699
+ * never throw before their implementations land.
700
+ */
701
+ export function AiChat({
702
+ agent,
703
+ apiPath,
704
+ pageContext,
705
+ attachmentIds,
706
+ initialMessages,
707
+ debug,
708
+ className,
709
+ placeholder,
710
+ onMutationRequested,
711
+ onError,
712
+ registry,
713
+ uiParts: uiPartsProp,
714
+ debugTools,
715
+ debugPromptSections,
716
+ conversationId,
717
+ suggestions,
718
+ contextItems,
719
+ welcomeTitle,
720
+ welcomeDescription,
721
+ }: AiChatProps) {
722
+ const t = useT()
723
+ const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
724
+ const transcriptRef = React.useRef<HTMLDivElement | null>(null)
725
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null)
726
+ const [input, setInput] = React.useState('')
727
+ type PendingAttachment = {
728
+ file: File
729
+ attachmentId?: string
730
+ /**
731
+ * Base64 data URL of the image (capped by PREVIEW_DATA_URL_MAX_BYTES).
732
+ * Stored on the message so the preview survives a reload — durable
733
+ * server URLs were intentionally avoided because the LLM provider can
734
+ * never reach a localhost dev URL anyway, and HTTP fetches add latency
735
+ * the chat doesn't need.
736
+ */
737
+ previewDataUrl?: string
738
+ error?: string
739
+ }
740
+ const [pendingFiles, setPendingFiles] = React.useState<PendingAttachment[]>([])
741
+ const upload = useAiChatUpload()
742
+ const isUploading = upload.busy
743
+
744
+ const uploadedAttachmentIds = React.useMemo(
745
+ () =>
746
+ pendingFiles
747
+ .map((entry) => entry.attachmentId)
748
+ .filter((id): id is string => typeof id === 'string' && id.length > 0),
749
+ [pendingFiles],
750
+ )
751
+
752
+ const allAttachmentIds = React.useMemo(
753
+ () => [...(attachmentIds ?? []), ...uploadedAttachmentIds],
754
+ [attachmentIds, uploadedAttachmentIds],
755
+ )
756
+
757
+ const chat = useAiChat({
758
+ agent,
759
+ apiPath,
760
+ pageContext,
761
+ attachmentIds: allAttachmentIds.length > 0 ? allAttachmentIds : undefined,
762
+ debug,
763
+ initialMessages,
764
+ onError,
765
+ conversationId,
766
+ })
767
+
768
+ const isStreaming = chat.status === 'streaming'
769
+ const isSubmitting = chat.status === 'submitting'
770
+ const isBusy = isStreaming || isSubmitting
771
+
772
+ // Surface a "Thinking..." placeholder so the chat does not look frozen.
773
+ // Visible whenever ANY of the following is true while a turn is in flight:
774
+ // (a) we're still in the submit phase before the first stream chunk
775
+ // (b) streaming, but no content / reasoning / tool calls have arrived yet
776
+ // (c) streaming, and at least one tool call is still in `pending` state
777
+ // (the model is waiting on a tool result — the previous version
778
+ // treated `toolCalls.length > 0` as "has content" and hid the
779
+ // indicator the moment the first tool started, even though the
780
+ // model had not produced any user-visible output yet)
781
+ // (d) streaming, and the last visible event was a finished tool call
782
+ // — the model is reasoning about the result before emitting more
783
+ // text or kicking off the next tool
784
+ // (e) streaming, but no delta has landed in the last ~300 ms (idle gap)
785
+ const lastAssistant = React.useMemo(() => {
786
+ for (let index = chat.messages.length - 1; index >= 0; index -= 1) {
787
+ const candidate = chat.messages[index]
788
+ if (candidate?.role === 'assistant') return candidate
789
+ }
790
+ return null
791
+ }, [chat.messages])
792
+
793
+ const trimmedContent = lastAssistant?.content?.trim() ?? ''
794
+ const hasReasoning = !!(lastAssistant?.reasoning && lastAssistant.reasoning.length > 0)
795
+ const toolCalls = lastAssistant?.toolCalls ?? []
796
+ const hasPendingToolCall = toolCalls.some((call) => call.state === 'pending')
797
+ const hasCompletedToolCall = toolCalls.some(
798
+ (call) => call.state === 'complete' || call.state === 'error',
799
+ )
800
+ const hasAnyVisibleSignal = !!(
801
+ trimmedContent || hasReasoning || toolCalls.length > 0
802
+ )
803
+
804
+ const assistantStreamSnapshot = React.useMemo(() => {
805
+ if (!lastAssistant) return ''
806
+ const toolSig = toolCalls
807
+ .map((call) => `${call.id}:${call.state}:${call.output != null ? 1 : 0}`)
808
+ .join('|')
809
+ return [
810
+ lastAssistant.id,
811
+ lastAssistant.content?.length ?? 0,
812
+ lastAssistant.reasoning?.length ?? 0,
813
+ lastAssistant.reasoningStreaming ? 1 : 0,
814
+ toolSig,
815
+ ].join('#')
816
+ // eslint-disable-next-line react-hooks/exhaustive-deps
817
+ }, [lastAssistant])
818
+
819
+ const lastStreamUpdateRef = React.useRef<number>(Date.now())
820
+ const lastSnapshotRef = React.useRef<string>('')
821
+ const [, setStreamTick] = React.useState(0)
822
+
823
+ React.useEffect(() => {
824
+ if (assistantStreamSnapshot !== lastSnapshotRef.current) {
825
+ lastSnapshotRef.current = assistantStreamSnapshot
826
+ lastStreamUpdateRef.current = Date.now()
827
+ setStreamTick((value) => value + 1)
828
+ }
829
+ }, [assistantStreamSnapshot])
830
+
831
+ React.useEffect(() => {
832
+ if (!isStreaming && !isSubmitting) return
833
+ const interval = window.setInterval(() => {
834
+ setStreamTick((value) => value + 1)
835
+ }, 200)
836
+ return () => window.clearInterval(interval)
837
+ }, [isStreaming, isSubmitting])
838
+
839
+ const idleDuringStream =
840
+ isStreaming && Date.now() - lastStreamUpdateRef.current >= 300
841
+
842
+ const showThinkingIndicator =
843
+ isSubmitting ||
844
+ (isStreaming &&
845
+ (
846
+ !hasAnyVisibleSignal ||
847
+ hasPendingToolCall ||
848
+ // Tool just returned and the model hasn't started speaking yet.
849
+ (hasCompletedToolCall && !trimmedContent) ||
850
+ idleDuringStream
851
+ ))
852
+
853
+ const activeRegistry = registry ?? defaultAiUiPartRegistry
854
+
855
+ // Reserved UI parts. Phase 3 will populate this from the streamed response;
856
+ // for Phase 2 WS-A it stays empty unless the host surfaces test/debug parts
857
+ // via the optional `uiParts` prop so the registry resolution path can be
858
+ // exercised without waiting for the runtime emitter.
859
+ const uiParts: ServerEmittedUiPartRef[] = React.useMemo(
860
+ () => (uiPartsProp ?? []).map((part) => ({
861
+ componentId: part.componentId,
862
+ payload: part.payload,
863
+ pendingActionId: part.pendingActionId,
864
+ })),
865
+ [uiPartsProp],
866
+ )
867
+
868
+ const hasUploadingFiles = React.useMemo(
869
+ () => pendingFiles.some((entry) => !entry.attachmentId && !entry.error),
870
+ [pendingFiles],
871
+ )
872
+
873
+ const handleSendMessage = React.useCallback(
874
+ (text: string) => {
875
+ if (!text.trim() || isBusy) return
876
+ // Block send while any attachment is still uploading. Without this guard
877
+ // the message would ship with an empty attachmentIds list (the chip is
878
+ // visible but the server hasn't returned an id yet), the model would
879
+ // never see the file, and `setPendingFiles([])` below would erase the
880
+ // chip — so the upload finishes into the void. Surface the wait via the
881
+ // disabled Send button + composer hint instead.
882
+ if (hasUploadingFiles || isUploading) return
883
+ const filesToAttach = pendingFiles.map((entry): AiChatMessageFile => {
884
+ const isImage = entry.file.type.startsWith('image/')
885
+ const fallback = isImage ? URL.createObjectURL(entry.file) : undefined
886
+ return {
887
+ name: entry.file.name,
888
+ type: entry.file.type,
889
+ previewUrl: isImage ? (entry.previewDataUrl ?? fallback) : undefined,
890
+ }
891
+ })
892
+ setInput('')
893
+ setPendingFiles([])
894
+ void chat.sendMessage(text, filesToAttach.length > 0 ? filesToAttach : undefined)
895
+ },
896
+ [chat, hasUploadingFiles, isBusy, isUploading, pendingFiles],
897
+ )
898
+
899
+ const handleSubmit = React.useCallback(() => {
900
+ handleSendMessage(input)
901
+ }, [handleSendMessage, input])
902
+
903
+ // Listen for "Fix with AI" requests dispatched by the failure variant
904
+ // of `MutationResultCard`. Any rendered failure card can fire a custom
905
+ // DOM event with the prompt — this side-steps having to thread a
906
+ // sendMessage callback through the UI-part registry while keeping the
907
+ // chat the single owner of message creation. Idempotency is handled
908
+ // server-side: `prepareMutation` only dedupes against active `pending`
909
+ // rows, so a retry after a terminal failure always produces a fresh
910
+ // pending action.
911
+ React.useEffect(() => {
912
+ if (typeof window === 'undefined') return
913
+ const handler = (event: Event) => {
914
+ const detail = (event as CustomEvent<{ message?: string }>).detail
915
+ const message = detail?.message
916
+ if (typeof message !== 'string' || message.trim().length === 0) return
917
+ handleSendMessage(message)
918
+ }
919
+ window.addEventListener('om-ai-chat-fix-request', handler as EventListener)
920
+ return () => {
921
+ window.removeEventListener('om-ai-chat-fix-request', handler as EventListener)
922
+ }
923
+ }, [handleSendMessage])
924
+
925
+ const cancelOrBlur = React.useCallback(() => {
926
+ if (isBusy) {
927
+ chat.cancel()
928
+ return
929
+ }
930
+ textareaRef.current?.blur()
931
+ }, [chat, isBusy])
932
+
933
+ const handleFileSelect = React.useCallback(
934
+ async (event: React.ChangeEvent<HTMLInputElement>) => {
935
+ const files = Array.from(event.target.files ?? [])
936
+ if (files.length === 0) return
937
+ const queued: PendingAttachment[] = files.map((file) => ({ file }))
938
+ setPendingFiles((prev) => [...prev, ...queued])
939
+ const [previewResults, result] = await Promise.all([
940
+ Promise.all(files.map((file) => readFileAsDataUrl(file))),
941
+ upload.upload(files),
942
+ ])
943
+ // Pair upload outcomes back to chips by input INDEX, not by filename.
944
+ // The server may sanitize the uploaded name (whitespace, unicode,
945
+ // dangerous characters), and two files in the same batch can share a
946
+ // name — both cases broke the previous Map-by-fileName matching and
947
+ // left the chip stuck on the spinner forever.
948
+ const idByIndex = new Map<number, string>()
949
+ for (const item of result.items) {
950
+ if (typeof item.inputIndex === 'number') idByIndex.set(item.inputIndex, item.attachmentId)
951
+ }
952
+ const errorByIndex = new Map<number, string>()
953
+ for (const failure of result.failed) {
954
+ if (typeof failure.inputIndex === 'number') errorByIndex.set(failure.inputIndex, failure.message)
955
+ }
956
+ setPendingFiles((prev) => {
957
+ const next = prev.slice()
958
+ const baseIndex = next.length - files.length
959
+ for (let offset = 0; offset < files.length; offset += 1) {
960
+ const index = baseIndex + offset
961
+ if (index < 0) continue
962
+ const entry = next[index]
963
+ if (!entry) continue
964
+ const dataUrl = previewResults[offset]
965
+ const patch: PendingAttachment = {
966
+ ...entry,
967
+ previewDataUrl: dataUrl ?? entry.previewDataUrl,
968
+ }
969
+ if (!patch.attachmentId) {
970
+ const id = idByIndex.get(offset)
971
+ if (id) {
972
+ patch.attachmentId = id
973
+ patch.error = undefined
974
+ } else {
975
+ // Defensive fallback: if neither success nor failure carried an
976
+ // index for this slot (older transports, partial outcome), the
977
+ // chip would otherwise stay on the spinner. Mark it as a
978
+ // generic error so the user can remove it and retry instead of
979
+ // staring at a dead spinner that also blocks the Send button.
980
+ const explicitError = errorByIndex.get(offset)
981
+ patch.error =
982
+ explicitError ??
983
+ 'Upload finished without a server response. Remove the file and try again.'
984
+ }
985
+ }
986
+ next[index] = patch
987
+ }
988
+ return next
989
+ })
990
+ if (fileInputRef.current) fileInputRef.current.value = ''
991
+ },
992
+ [upload],
993
+ )
994
+
995
+ const removePendingFile = React.useCallback((index: number) => {
996
+ setPendingFiles((prev) => prev.filter((_, i) => i !== index))
997
+ }, [])
998
+
999
+ const { handleKeyDown } = useAiShortcuts({
1000
+ onSubmit: handleSubmit,
1001
+ onCancel: cancelOrBlur,
1002
+ })
1003
+
1004
+ React.useEffect(() => {
1005
+ textareaRef.current?.focus()
1006
+ }, [])
1007
+
1008
+ // Sticky-bottom autoscroll: only re-pin to the bottom when the user is
1009
+ // already there (or within a small tolerance). If they have scrolled up to
1010
+ // read an earlier part of a long response, every streaming delta would
1011
+ // otherwise yank them back to the tail and make the message look truncated.
1012
+ // Tolerance is generous enough to absorb sub-pixel rounding, but tight
1013
+ // enough that an intentional scroll-up keeps the user where they want.
1014
+ const stickToBottomRef = React.useRef(true)
1015
+ const SCROLL_STICK_TOLERANCE_PX = 64
1016
+
1017
+ React.useEffect(() => {
1018
+ const node = transcriptRef.current
1019
+ if (!node) return
1020
+ const handleScroll = () => {
1021
+ const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight
1022
+ stickToBottomRef.current = distanceFromBottom <= SCROLL_STICK_TOLERANCE_PX
1023
+ }
1024
+ node.addEventListener('scroll', handleScroll, { passive: true })
1025
+ return () => node.removeEventListener('scroll', handleScroll)
1026
+ }, [])
1027
+
1028
+ React.useEffect(() => {
1029
+ const node = transcriptRef.current
1030
+ if (!node) return
1031
+ if (!stickToBottomRef.current) return
1032
+ node.scrollTop = node.scrollHeight
1033
+ }, [chat.messages])
1034
+
1035
+ // Mark the body so floating UI surfaces (e.g. the demo feedback FAB) can
1036
+ // hide themselves while the chat is open and would otherwise overlay the
1037
+ // composer's send button. CSS lives in `apps/mercato/src/app/globals.css`
1038
+ // alongside the column-chooser precedent.
1039
+ React.useEffect(() => {
1040
+ if (typeof document === 'undefined') return
1041
+ const previous = document.body.getAttribute('data-ai-chat-open')
1042
+ document.body.setAttribute('data-ai-chat-open', 'true')
1043
+ return () => {
1044
+ if (previous === null) {
1045
+ document.body.removeAttribute('data-ai-chat-open')
1046
+ } else {
1047
+ document.body.setAttribute('data-ai-chat-open', previous)
1048
+ }
1049
+ }
1050
+ }, [])
1051
+
1052
+ const handleNewConversation = React.useCallback(() => {
1053
+ chat.reset()
1054
+ setInput('')
1055
+ setPendingFiles([])
1056
+ upload.reset()
1057
+ setTimeout(() => textareaRef.current?.focus(), 0)
1058
+ }, [chat, upload])
1059
+
1060
+ const resolvedPlaceholder =
1061
+ placeholder ?? t('ai_assistant.chat.composerPlaceholder', 'Message the AI agent...')
1062
+
1063
+ const errorVariant = mapErrorCodeToVariant(chat.error?.code)
1064
+
1065
+ return (
1066
+ <section
1067
+ className={cn(
1068
+ 'flex h-full min-h-[320px] flex-col gap-3 rounded-lg border border-border bg-background p-3',
1069
+ className,
1070
+ )}
1071
+ aria-label={t('ai_assistant.chat.regionLabel', 'AI chat')}
1072
+ data-ai-chat-agent={agent}
1073
+ data-ai-chat-conversation-id={chat.conversationId}
1074
+ >
1075
+ <div className="flex items-center justify-between gap-2 border-b border-border pb-2">
1076
+ <div className="flex flex-1 items-center gap-2 text-xs text-muted-foreground">
1077
+ {contextItems && contextItems.length > 0 ? (
1078
+ <ContextItemsPill items={contextItems} />
1079
+ ) : (
1080
+ <span className="font-mono opacity-70" aria-hidden>
1081
+ {chat.conversationId.slice(0, 8)}
1082
+ </span>
1083
+ )}
1084
+ </div>
1085
+ <IconButton
1086
+ type="button"
1087
+ variant="ghost"
1088
+ size="sm"
1089
+ onClick={handleNewConversation}
1090
+ disabled={isBusy}
1091
+ aria-label={t('ai_assistant.chat.newConversation', 'Start new conversation')}
1092
+ title={t('ai_assistant.chat.newConversation', 'Start new conversation')}
1093
+ data-ai-chat-new-conversation=""
1094
+ >
1095
+ <Plus className="size-4" aria-hidden />
1096
+ </IconButton>
1097
+ </div>
1098
+ <div
1099
+ ref={transcriptRef}
1100
+ role="log"
1101
+ aria-live="polite"
1102
+ aria-label={t('ai_assistant.chat.transcriptLabel', 'Chat transcript')}
1103
+ className="flex-1 space-y-2 overflow-y-auto pr-1"
1104
+ >
1105
+ {chat.messages.length === 0 ? (
1106
+ <WelcomeState
1107
+ title={welcomeTitle}
1108
+ description={welcomeDescription}
1109
+ suggestions={suggestions}
1110
+ onSuggestionClick={handleSendMessage}
1111
+ />
1112
+ ) : (
1113
+ chat.messages.map((message) => (
1114
+ <MessageRow
1115
+ key={message.id}
1116
+ message={message}
1117
+ registry={activeRegistry}
1118
+ onMutationRequested={onMutationRequested}
1119
+ />
1120
+ ))
1121
+ )}
1122
+ {uiParts.map((part, index) => (
1123
+ <AiUiPartRenderer
1124
+ key={`${part.componentId}-${index}`}
1125
+ part={part}
1126
+ registry={activeRegistry}
1127
+ onMutationRequested={onMutationRequested}
1128
+ />
1129
+ ))}
1130
+ {showThinkingIndicator ? (
1131
+ <div
1132
+ className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
1133
+ data-ai-chat-state="thinking"
1134
+ >
1135
+ <Loader2 className="size-4 animate-spin" aria-hidden />
1136
+ <span>{t('ai_assistant.chat.thinking', 'Thinking...')}</span>
1137
+ </div>
1138
+ ) : null}
1139
+ </div>
1140
+
1141
+ {chat.error ? (
1142
+ <Alert variant={errorVariant} data-ai-chat-error={chat.error.code ?? 'unknown'}>
1143
+ <AlertTitle>
1144
+ {t('ai_assistant.chat.errorTitle', 'Agent dispatch failed')}
1145
+ </AlertTitle>
1146
+ <AlertDescription>
1147
+ {chat.error.code ? (
1148
+ <span className="mr-2 font-mono text-xs">{chat.error.code}</span>
1149
+ ) : null}
1150
+ {chat.error.message}
1151
+ </AlertDescription>
1152
+ </Alert>
1153
+ ) : null}
1154
+
1155
+ <form
1156
+ className="flex flex-col gap-2"
1157
+ onSubmit={(event) => {
1158
+ event.preventDefault()
1159
+ handleSubmit()
1160
+ }}
1161
+ >
1162
+ <Label
1163
+ htmlFor="ai-chat-composer"
1164
+ className="sr-only"
1165
+ >
1166
+ {t('ai_assistant.chat.composerLabel', 'Message composer')}
1167
+ </Label>
1168
+ {pendingFiles.length > 0 ? (
1169
+ <div className="flex flex-wrap gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1.5" data-ai-chat-attachments="">
1170
+ {pendingFiles.map((entry, index) => (
1171
+ <span
1172
+ key={index}
1173
+ className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2 py-0.5 text-xs"
1174
+ title={entry.error ? entry.error : undefined}
1175
+ data-ai-chat-attachment-state={
1176
+ entry.error ? 'error' : entry.attachmentId ? 'ready' : 'uploading'
1177
+ }
1178
+ >
1179
+ <Paperclip className="size-3 text-muted-foreground" aria-hidden />
1180
+ <span className="max-w-[120px] truncate">{entry.file.name}</span>
1181
+ {!entry.attachmentId && !entry.error ? (
1182
+ <Loader2 className="size-3 animate-spin text-muted-foreground" aria-hidden />
1183
+ ) : null}
1184
+ <button
1185
+ type="button"
1186
+ className="ml-0.5 rounded-full p-0.5 hover:bg-muted"
1187
+ onClick={() => removePendingFile(index)}
1188
+ aria-label={t('ai_assistant.chat.removeFile', 'Remove file')}
1189
+ >
1190
+ <X className="size-3" />
1191
+ </button>
1192
+ </span>
1193
+ ))}
1194
+ {isUploading ? <Loader2 className="size-3 animate-spin text-muted-foreground" aria-hidden /> : null}
1195
+ </div>
1196
+ ) : null}
1197
+ <Textarea
1198
+ id="ai-chat-composer"
1199
+ ref={textareaRef}
1200
+ value={input}
1201
+ placeholder={resolvedPlaceholder}
1202
+ onChange={(event) => setInput(event.target.value)}
1203
+ onKeyDown={handleKeyDown}
1204
+ rows={3}
1205
+ aria-label={t('ai_assistant.chat.composerLabel', 'Message composer')}
1206
+ className="resize-none"
1207
+ />
1208
+ <input
1209
+ ref={fileInputRef}
1210
+ type="file"
1211
+ multiple
1212
+ accept="image/*,.pdf,.doc,.docx,.txt,.csv"
1213
+ className="hidden"
1214
+ onChange={handleFileSelect}
1215
+ data-ai-chat-file-input=""
1216
+ />
1217
+ <div className="flex items-center justify-between gap-2">
1218
+ <div className="flex items-center gap-2">
1219
+ <IconButton
1220
+ type="button"
1221
+ variant="ghost"
1222
+ size="sm"
1223
+ onClick={() => fileInputRef.current?.click()}
1224
+ disabled={isBusy || isUploading}
1225
+ aria-label={t('ai_assistant.chat.attachFile', 'Attach file')}
1226
+ >
1227
+ <Paperclip className="size-4" aria-hidden />
1228
+ </IconButton>
1229
+ <p className="text-xs text-muted-foreground">
1230
+ {hasUploadingFiles || isUploading
1231
+ ? t(
1232
+ 'ai_assistant.chat.uploadingHint',
1233
+ 'Uploading attachments… Send is disabled until they finish.',
1234
+ )
1235
+ : t(
1236
+ 'ai_assistant.chat.shortcutHint',
1237
+ 'Press Enter to send, Shift+Enter for new line.',
1238
+ )}
1239
+ </p>
1240
+ </div>
1241
+ <div className="flex items-center gap-2">
1242
+ {isStreaming ? (
1243
+ <IconButton
1244
+ type="button"
1245
+ variant="outline"
1246
+ size="sm"
1247
+ onClick={() => chat.cancel()}
1248
+ aria-label={t('ai_assistant.chat.cancel', 'Cancel streaming response')}
1249
+ >
1250
+ <Square className="size-4" aria-hidden />
1251
+ </IconButton>
1252
+ ) : null}
1253
+ <Button
1254
+ type="submit"
1255
+ size="sm"
1256
+ disabled={
1257
+ isBusy ||
1258
+ isUploading ||
1259
+ hasUploadingFiles ||
1260
+ input.trim().length === 0
1261
+ }
1262
+ aria-label={
1263
+ hasUploadingFiles || isUploading
1264
+ ? t('ai_assistant.chat.sendWaitingForUpload', 'Waiting for upload to finish…')
1265
+ : t('ai_assistant.chat.send', 'Send message')
1266
+ }
1267
+ title={
1268
+ hasUploadingFiles || isUploading
1269
+ ? t('ai_assistant.chat.sendWaitingForUpload', 'Waiting for upload to finish…')
1270
+ : undefined
1271
+ }
1272
+ >
1273
+ <Send className="size-4" aria-hidden />
1274
+ <span>{t('ai_assistant.chat.send', 'Send message')}</span>
1275
+ </Button>
1276
+ </div>
1277
+ </div>
1278
+ </form>
1279
+
1280
+ {debug ? (
1281
+ <AiChatDebugPanel
1282
+ tools={debugTools}
1283
+ promptSections={debugPromptSections}
1284
+ lastRequestDebug={chat.lastRequestDebug}
1285
+ lastResponseDebug={chat.lastResponseDebug}
1286
+ status={chat.status}
1287
+ errorCode={chat.error?.code}
1288
+ />
1289
+ ) : null}
1290
+ </section>
1291
+ )
1292
+ }
1293
+
1294
+ interface DebugPanelProps {
1295
+ tools?: AiChatDebugTool[]
1296
+ promptSections?: AiChatDebugPromptSection[]
1297
+ lastRequestDebug: { url: string; body: unknown } | null
1298
+ lastResponseDebug: { status: number; text: string } | null
1299
+ status: 'idle' | 'submitting' | 'streaming'
1300
+ errorCode?: string
1301
+ }
1302
+
1303
+ function AiChatDebugPanel({
1304
+ tools,
1305
+ promptSections,
1306
+ lastRequestDebug,
1307
+ lastResponseDebug,
1308
+ status,
1309
+ errorCode,
1310
+ }: DebugPanelProps) {
1311
+ const t = useT()
1312
+ return (
1313
+ <div
1314
+ className="flex flex-col gap-2 rounded-md border border-border bg-muted/60 p-2 text-xs"
1315
+ data-ai-chat-debug="true"
1316
+ >
1317
+ <div className="font-semibold">
1318
+ {t('ai_assistant.chat.debug.panelTitle', 'Debug panel')}
1319
+ </div>
1320
+
1321
+ <details className="rounded border border-border bg-background" data-ai-chat-debug-section="tools" open>
1322
+ <summary className="cursor-pointer px-2 py-1 font-semibold">
1323
+ {t('ai_assistant.chat.debug.toolsSection', 'Resolved tools')}
1324
+ {tools ? (
1325
+ <span className="ml-2 font-mono text-muted-foreground">({tools.length})</span>
1326
+ ) : null}
1327
+ </summary>
1328
+ <div className="px-2 pb-2">
1329
+ {tools && tools.length > 0 ? (
1330
+ <ul className="flex flex-col gap-1" data-ai-chat-debug-tools>
1331
+ {tools.map((tool) => (
1332
+ <li
1333
+ key={tool.name}
1334
+ className="flex flex-col rounded border border-border bg-muted/40 px-2 py-1"
1335
+ data-ai-chat-debug-tool={tool.name}
1336
+ >
1337
+ <span className="font-mono">{tool.name}</span>
1338
+ {tool.displayName ? (
1339
+ <span className="text-muted-foreground">{tool.displayName}</span>
1340
+ ) : null}
1341
+ <span className="mt-1 flex flex-wrap gap-2 text-muted-foreground">
1342
+ <span>
1343
+ {tool.isMutation
1344
+ ? t('ai_assistant.chat.debug.toolMutation', 'mutation')
1345
+ : t('ai_assistant.chat.debug.toolRead', 'read')}
1346
+ </span>
1347
+ {tool.requiredFeatures && tool.requiredFeatures.length > 0 ? (
1348
+ <span className="font-mono">
1349
+ [{tool.requiredFeatures.join(', ')}]
1350
+ </span>
1351
+ ) : (
1352
+ <span>
1353
+ {t('ai_assistant.chat.debug.toolNoFeatures', 'no required features')}
1354
+ </span>
1355
+ )}
1356
+ </span>
1357
+ </li>
1358
+ ))}
1359
+ </ul>
1360
+ ) : (
1361
+ <p className="text-muted-foreground">
1362
+ {t(
1363
+ 'ai_assistant.chat.debug.toolsEmpty',
1364
+ 'No tools resolved for this agent yet.',
1365
+ )}
1366
+ </p>
1367
+ )}
1368
+ </div>
1369
+ </details>
1370
+
1371
+ <details
1372
+ className="rounded border border-border bg-background"
1373
+ data-ai-chat-debug-section="promptSections"
1374
+ >
1375
+ <summary className="cursor-pointer px-2 py-1 font-semibold">
1376
+ {t('ai_assistant.chat.debug.promptSection', 'Prompt sections')}
1377
+ {promptSections ? (
1378
+ <span className="ml-2 font-mono text-muted-foreground">({promptSections.length})</span>
1379
+ ) : null}
1380
+ </summary>
1381
+ <div className="px-2 pb-2">
1382
+ {promptSections && promptSections.length > 0 ? (
1383
+ <ul className="flex flex-col gap-1" data-ai-chat-debug-prompt-sections>
1384
+ {promptSections.map((section) => (
1385
+ <li
1386
+ key={section.id}
1387
+ className="rounded border border-border bg-muted/40 px-2 py-1"
1388
+ data-ai-chat-debug-prompt-section-id={section.id}
1389
+ >
1390
+ <div className="flex items-center justify-between gap-2">
1391
+ <span className="font-mono">{section.id}</span>
1392
+ <span className="text-muted-foreground">
1393
+ {section.source === 'override'
1394
+ ? t('ai_assistant.chat.debug.promptOverride', 'override')
1395
+ : section.source === 'placeholder'
1396
+ ? t('ai_assistant.chat.debug.promptPlaceholder', 'placeholder')
1397
+ : t('ai_assistant.chat.debug.promptDefault', 'default')}
1398
+ </span>
1399
+ </div>
1400
+ {section.text ? (
1401
+ <pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap font-mono text-muted-foreground">
1402
+ {section.text}
1403
+ </pre>
1404
+ ) : null}
1405
+ </li>
1406
+ ))}
1407
+ </ul>
1408
+ ) : (
1409
+ <p className="text-muted-foreground">
1410
+ {t(
1411
+ 'ai_assistant.chat.debug.promptEmpty',
1412
+ 'No prompt sections resolved for this agent.',
1413
+ )}
1414
+ </p>
1415
+ )}
1416
+ </div>
1417
+ </details>
1418
+
1419
+ <details
1420
+ className="rounded border border-border bg-background"
1421
+ data-ai-chat-debug-section="lastRequest"
1422
+ >
1423
+ <summary className="cursor-pointer px-2 py-1 font-semibold">
1424
+ {t('ai_assistant.chat.debug.lastRequestSection', 'Last request')}
1425
+ </summary>
1426
+ <div className="px-2 pb-2">
1427
+ {lastRequestDebug ? (
1428
+ <pre
1429
+ className="max-h-40 overflow-auto whitespace-pre-wrap font-mono"
1430
+ data-ai-chat-debug-last-request
1431
+ >
1432
+ {JSON.stringify(lastRequestDebug, null, 2)}
1433
+ </pre>
1434
+ ) : (
1435
+ <p className="text-muted-foreground">
1436
+ {t(
1437
+ 'ai_assistant.chat.debug.lastRequestEmpty',
1438
+ 'No request has been sent yet.',
1439
+ )}
1440
+ </p>
1441
+ )}
1442
+ </div>
1443
+ </details>
1444
+
1445
+ <details
1446
+ className="rounded border border-border bg-background"
1447
+ data-ai-chat-debug-section="lastResponse"
1448
+ >
1449
+ <summary className="cursor-pointer px-2 py-1 font-semibold">
1450
+ {t('ai_assistant.chat.debug.lastResponseSection', 'Last response')}
1451
+ </summary>
1452
+ <div className="px-2 pb-2">
1453
+ {lastResponseDebug ? (
1454
+ <pre
1455
+ className="max-h-40 overflow-auto whitespace-pre-wrap font-mono"
1456
+ data-ai-chat-debug-last-response
1457
+ >
1458
+ {JSON.stringify(
1459
+ { status: lastResponseDebug.status, text: lastResponseDebug.text, errorCode },
1460
+ null,
1461
+ 2,
1462
+ )}
1463
+ </pre>
1464
+ ) : (
1465
+ <p className="text-muted-foreground">
1466
+ {t(
1467
+ 'ai_assistant.chat.debug.lastResponseEmpty',
1468
+ 'No response received yet.',
1469
+ )}
1470
+ </p>
1471
+ )}
1472
+ </div>
1473
+ </details>
1474
+
1475
+ <div className="text-muted-foreground" data-ai-chat-debug-status={status}>
1476
+ {t('ai_assistant.chat.debug.statusLabel', 'Status:')}{' '}
1477
+ <span className="font-mono">{status}</span>
1478
+ </div>
1479
+ </div>
1480
+ )
1481
+ }
1482
+
1483
+ export default AiChat