@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,805 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Global AI assistant launcher.
5
+ *
6
+ * Reusable component that:
7
+ * - Hides itself when the AI runtime is not configured (no provider key,
8
+ * `/api/ai_assistant/health` returns non-OK, or `/api/ai_assistant/ai/agents`
9
+ * returns zero accessible agents for the caller).
10
+ * - Exposes a compact icon trigger styled for the topbar.
11
+ * - Opens a Cmd-K-style searchable dialog listing every typed agent the
12
+ * caller is allowed to launch — searchable by label, description, or id —
13
+ * so it scales to many assistants.
14
+ * - On agent select, opens `<AiChat>` in a right-side sheet with empty
15
+ * `pageContext` (the picker is intentionally page-agnostic; per-page
16
+ * triggers continue to embed `<AiChat>` directly with their own context).
17
+ * - Binds a global keyboard shortcut (default Cmd/Ctrl+L) that opens the
18
+ * picker. Cmd+K stays reserved for global search; Cmd+J stays reserved
19
+ * for the legacy OpenCode command palette. Browsers normally use
20
+ * Cmd/Ctrl+L for "focus address bar"; we `preventDefault()` so the
21
+ * launcher wins when an Open Mercato page has focus.
22
+ */
23
+
24
+ import * as React from 'react'
25
+ import {
26
+ AlertTriangle,
27
+ Bot,
28
+ ExternalLink,
29
+ HelpCircle,
30
+ Lightbulb,
31
+ Loader2,
32
+ PanelRightOpen,
33
+ Search,
34
+ Sparkles,
35
+ } from 'lucide-react'
36
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
37
+ import { cn } from '@open-mercato/shared/lib/utils'
38
+ import { apiCall } from '../backend/utils/apiCall'
39
+ import {
40
+ Dialog,
41
+ DialogContent,
42
+ DialogDescription,
43
+ DialogHeader,
44
+ DialogTitle,
45
+ } from '../primitives/dialog'
46
+ import { Button } from '../primitives/button'
47
+ import { IconButton } from '../primitives/icon-button'
48
+ import { Kbd, KbdShortcut } from '../primitives/kbd'
49
+ import { useAiDock } from './AiDock'
50
+ import { useAiChatSessions } from './AiChatSessions'
51
+ import { ChatPaneTabs } from './ChatPaneTabs'
52
+ import type { AiChatContextItem, AiChatSuggestion } from './AiChat'
53
+
54
+ // Lazy-load the chat surface so AppShell tests (and any other importers that
55
+ // don't actually open the launcher) avoid pulling the AI SDK runtime — which
56
+ // touches `TransformStream` and breaks under jsdom.
57
+ const LazyAiChat = React.lazy(async () => {
58
+ const mod = await import('./AiChat')
59
+ return { default: mod.AiChat }
60
+ })
61
+
62
+ export interface AiAssistantLauncherAgent {
63
+ id: string
64
+ label: string
65
+ description?: string | null
66
+ moduleId?: string | null
67
+ /**
68
+ * `read-only` (default), `confirm-required`, or
69
+ * `destructive-confirm-required`. Surfaced as a small badge in the picker
70
+ * row so operators can see at a glance which assistants can write.
71
+ */
72
+ mutationPolicy?: string | null
73
+ keywords?: string[]
74
+ suggestions?: AiChatSuggestion[]
75
+ }
76
+
77
+ export interface AiAssistantLauncherProps {
78
+ /**
79
+ * Trigger placement. `topbar` (default) = rounded-rectangle pill button
80
+ * matching the global-search trigger (icon + "AI" label + ⌘L kbd hint).
81
+ * `inline` is a back-compat alias and renders the same trigger.
82
+ */
83
+ variant?: 'topbar' | 'inline'
84
+ /**
85
+ * Optional override of the agents endpoint. Defaults to
86
+ * `/api/ai_assistant/ai/agents` from the typed agent dispatcher.
87
+ */
88
+ agentsEndpoint?: string
89
+ /**
90
+ * Optional override of the health endpoint. Defaults to
91
+ * `/api/ai_assistant/health`. The launcher hides itself when this returns
92
+ * non-2xx OR a JSON body with `{ healthy: false }`.
93
+ */
94
+ healthEndpoint?: string
95
+ /**
96
+ * When true, skip the health check (useful in tests / hosts that already
97
+ * gated visibility by feature). Defaults to false.
98
+ */
99
+ skipHealthCheck?: boolean
100
+ /**
101
+ * Disable the global keyboard shortcut binding (Cmd/Ctrl+L). Useful for
102
+ * nested launchers in dialogs / portals where the host owns shortcut
103
+ * handling.
104
+ */
105
+ disableGlobalShortcut?: boolean
106
+ className?: string
107
+ }
108
+
109
+ interface AgentsResponse {
110
+ agents?: Array<{
111
+ id?: string | null
112
+ label?: string | null
113
+ description?: string | null
114
+ moduleId?: string | null
115
+ mutationPolicy?: string | null
116
+ keywords?: string[] | null
117
+ suggestions?: Array<{
118
+ label?: string | null
119
+ prompt?: string | null
120
+ }> | null
121
+ }>
122
+ aiConfigured?: boolean
123
+ }
124
+
125
+ interface HealthResponse {
126
+ healthy?: boolean
127
+ status?: string | null
128
+ }
129
+
130
+ const DEFAULT_AGENTS_ENDPOINT = '/api/ai_assistant/ai/agents'
131
+ const DEFAULT_HEALTH_ENDPOINT = '/api/ai_assistant/health'
132
+ const AI_ASSISTANT_DOCS_URL = 'https://docs.openmercato.com/framework/ai-assistant/overview'
133
+ const AI_ASSISTANT_SETTINGS_DOCS_URL = 'https://docs.openmercato.com/framework/ai-assistant/settings'
134
+
135
+ function isMutationCapable(policy: string | null | undefined): boolean {
136
+ return policy === 'confirm-required' || policy === 'destructive-confirm-required'
137
+ }
138
+
139
+ function normalizeAgents(payload: AgentsResponse | null | undefined): AiAssistantLauncherAgent[] {
140
+ if (!payload || !Array.isArray(payload.agents)) return []
141
+ const result: AiAssistantLauncherAgent[] = []
142
+ for (const raw of payload.agents) {
143
+ if (!raw || typeof raw.id !== 'string' || raw.id.length === 0) continue
144
+ if (typeof raw.label !== 'string' || raw.label.length === 0) continue
145
+ result.push({
146
+ id: raw.id,
147
+ label: raw.label,
148
+ description: typeof raw.description === 'string' ? raw.description : null,
149
+ moduleId: typeof raw.moduleId === 'string' ? raw.moduleId : null,
150
+ mutationPolicy:
151
+ typeof raw.mutationPolicy === 'string' ? raw.mutationPolicy : null,
152
+ keywords: Array.isArray(raw.keywords)
153
+ ? raw.keywords.filter((value): value is string => typeof value === 'string' && value.length > 0)
154
+ : [],
155
+ suggestions: Array.isArray(raw.suggestions)
156
+ ? raw.suggestions
157
+ .map((suggestion) => {
158
+ if (!suggestion) return null
159
+ if (typeof suggestion.label !== 'string' || typeof suggestion.prompt !== 'string') {
160
+ return null
161
+ }
162
+ if (!suggestion.label || !suggestion.prompt) return null
163
+ return { label: suggestion.label, prompt: suggestion.prompt }
164
+ })
165
+ .filter((suggestion): suggestion is AiChatSuggestion => suggestion !== null)
166
+ : [],
167
+ })
168
+ }
169
+ return result
170
+ }
171
+
172
+ function matchesQuery(agent: AiAssistantLauncherAgent, query: string): boolean {
173
+ if (!query) return true
174
+ const needle = query.trim().toLowerCase()
175
+ if (!needle) return true
176
+ if (agent.label.toLowerCase().includes(needle)) return true
177
+ if (agent.id.toLowerCase().includes(needle)) return true
178
+ if (agent.description && agent.description.toLowerCase().includes(needle)) return true
179
+ if (agent.moduleId && agent.moduleId.toLowerCase().includes(needle)) return true
180
+ if (agent.keywords && agent.keywords.some((keyword) => keyword.toLowerCase().includes(needle))) return true
181
+ return false
182
+ }
183
+
184
+ function isTextEntryTarget(target: EventTarget | null): boolean {
185
+ if (!target || !(target instanceof HTMLElement)) return false
186
+ const tag = target.tagName
187
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
188
+ if (target.isContentEditable) return true
189
+ return false
190
+ }
191
+
192
+ export function AiAssistantLauncher({
193
+ variant: _variant = 'topbar',
194
+ agentsEndpoint = DEFAULT_AGENTS_ENDPOINT,
195
+ healthEndpoint = DEFAULT_HEALTH_ENDPOINT,
196
+ skipHealthCheck = false,
197
+ disableGlobalShortcut = false,
198
+ className,
199
+ }: AiAssistantLauncherProps) {
200
+ const t = useT()
201
+ const dock = useAiDock()
202
+ const [healthy, setHealthy] = React.useState<boolean | null>(skipHealthCheck ? true : null)
203
+ const [agents, setAgents] = React.useState<AiAssistantLauncherAgent[]>([])
204
+ const [agentsLoaded, setAgentsLoaded] = React.useState(false)
205
+ const [agentsError, setAgentsError] = React.useState<string | null>(null)
206
+ // `aiConfigured: false` → no LLM provider key in env. Keep the launcher
207
+ // visible when assistants exist so operators get a concrete setup prompt
208
+ // instead of a disappearing control or a failing chat stream.
209
+ const [aiConfigured, setAiConfigured] = React.useState<boolean | null>(null)
210
+ const [pickerOpen, setPickerOpen] = React.useState(false)
211
+ const [activeAgent, setActiveAgent] = React.useState<AiAssistantLauncherAgent | null>(null)
212
+ const [chatOpen, setChatOpen] = React.useState(false)
213
+ const [query, setQuery] = React.useState('')
214
+ const [highlight, setHighlight] = React.useState(0)
215
+
216
+ // Health check — best-effort signal only. We do NOT gate the launcher
217
+ // behind it any more: a flaky / slow / transiently-401 health endpoint on
218
+ // page refresh used to leave the launcher permanently hidden because the
219
+ // agents effect short-circuited on `healthy !== true`. Now `healthy` is
220
+ // purely advisory; the launcher's visibility is driven by the agents
221
+ // endpoint, which is the authoritative source — it returns zero agents
222
+ // when AI is not configured anyway. Treat *any* non-explicit-false health
223
+ // response (including network errors and unreachable endpoints) as
224
+ // "probably healthy" so the agents fetch always runs.
225
+ React.useEffect(() => {
226
+ if (skipHealthCheck) return
227
+ let cancelled = false
228
+ apiCall<HealthResponse>(healthEndpoint, {
229
+ credentials: 'same-origin',
230
+ headers: { 'x-om-forbidden-redirect': '0', 'x-om-unauthorized-redirect': '0' },
231
+ })
232
+ .then((call) => {
233
+ if (cancelled) return
234
+ if (!call.ok) {
235
+ // Don't hide the launcher on 4xx/5xx — fall back to "unknown"
236
+ // (treated as healthy below) and let the agents endpoint decide.
237
+ setHealthy(true)
238
+ return
239
+ }
240
+ const body = call.result
241
+ if (body && typeof body === 'object' && body.healthy === false) {
242
+ setHealthy(false)
243
+ return
244
+ }
245
+ setHealthy(true)
246
+ })
247
+ .catch(() => {
248
+ if (cancelled) return
249
+ // Network errors are treated as "probably healthy" too — the
250
+ // agents endpoint is authoritative for visibility.
251
+ setHealthy(true)
252
+ })
253
+ return () => {
254
+ cancelled = true
255
+ }
256
+ }, [healthEndpoint, skipHealthCheck])
257
+
258
+ // Agents — fetched on mount, independently of the health check. The
259
+ // endpoint already filters by the caller's ACL features server-side, so
260
+ // an empty response is the right signal to hide the launcher. Loading
261
+ // these here (instead of behind `healthy === true`) makes the launcher
262
+ // resilient to a flaky / slow / transiently-401 health endpoint that
263
+ // would otherwise leave it permanently hidden after a page refresh.
264
+ React.useEffect(() => {
265
+ if (agentsLoaded) return
266
+ let cancelled = false
267
+ apiCall<AgentsResponse>(agentsEndpoint, {
268
+ credentials: 'same-origin',
269
+ headers: { 'x-om-forbidden-redirect': '0', 'x-om-unauthorized-redirect': '0' },
270
+ })
271
+ .then((call) => {
272
+ if (cancelled) return
273
+ if (!call.ok) {
274
+ setAgents([])
275
+ setAgentsError(`agents endpoint returned ${call.status}`)
276
+ setAgentsLoaded(true)
277
+ return
278
+ }
279
+ if (call.result) {
280
+ setAgents(normalizeAgents(call.result))
281
+ setAgentsError(null)
282
+ if (typeof call.result.aiConfigured === 'boolean') {
283
+ setAiConfigured(call.result.aiConfigured)
284
+ }
285
+ } else {
286
+ setAgents([])
287
+ setAgentsError('Empty agents response')
288
+ }
289
+ setAgentsLoaded(true)
290
+ })
291
+ .catch((error) => {
292
+ if (cancelled) return
293
+ setAgents([])
294
+ setAgentsError(error instanceof Error ? error.message : String(error))
295
+ setAgentsLoaded(true)
296
+ })
297
+ return () => {
298
+ cancelled = true
299
+ }
300
+ }, [agentsEndpoint, agentsLoaded])
301
+
302
+ const filteredAgents = React.useMemo(
303
+ () => agents.filter((agent) => matchesQuery(agent, query)),
304
+ [agents, query],
305
+ )
306
+
307
+ // Reset highlight when the filtered set changes; clamp to a valid index.
308
+ React.useEffect(() => {
309
+ if (filteredAgents.length === 0) {
310
+ if (highlight !== 0) setHighlight(0)
311
+ return
312
+ }
313
+ if (highlight >= filteredAgents.length) setHighlight(0)
314
+ }, [filteredAgents, highlight])
315
+
316
+ const openPicker = React.useCallback(() => {
317
+ setQuery('')
318
+ setHighlight(0)
319
+ setPickerOpen(true)
320
+ }, [])
321
+
322
+ const handleSelectAgent = React.useCallback((agent: AiAssistantLauncherAgent) => {
323
+ if (dock.state.assistant?.agent === agent.id) {
324
+ dock.dock(dock.state.assistant)
325
+ setPickerOpen(false)
326
+ setChatOpen(false)
327
+ return
328
+ }
329
+ setActiveAgent(agent)
330
+ setPickerOpen(false)
331
+ setChatOpen(true)
332
+ }, [dock])
333
+
334
+ // Global Cmd/Ctrl+L — opens the picker. We bind on `keydown` at the
335
+ // document level and ignore events from text-entry targets so it never
336
+ // interferes with typing inside inputs/textarea. Browsers normally focus
337
+ // the address bar on Cmd/Ctrl+L; `preventDefault()` reclaims the combo
338
+ // when an Open Mercato page has focus.
339
+ React.useEffect(() => {
340
+ if (disableGlobalShortcut) return
341
+ if (typeof window === 'undefined') return
342
+ if (healthy !== true) return
343
+ if (!agentsLoaded || agents.length === 0) return
344
+ const listener = (event: KeyboardEvent) => {
345
+ const isModifier = event.metaKey || event.ctrlKey
346
+ if (!isModifier) return
347
+ if (event.shiftKey || event.altKey) return
348
+ if (event.key !== 'l' && event.key !== 'L') return
349
+ if (isTextEntryTarget(event.target)) return
350
+ event.preventDefault()
351
+ openPicker()
352
+ }
353
+ window.addEventListener('keydown', listener)
354
+ return () => window.removeEventListener('keydown', listener)
355
+ }, [agents.length, agentsLoaded, disableGlobalShortcut, healthy, openPicker])
356
+
357
+ const handlePickerKeyDown = React.useCallback(
358
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
359
+ if (aiConfigured === false || filteredAgents.length === 0) {
360
+ if (event.key === 'Escape') {
361
+ setPickerOpen(false)
362
+ }
363
+ return
364
+ }
365
+ if (event.key === 'ArrowDown') {
366
+ event.preventDefault()
367
+ setHighlight((current) => (current + 1) % filteredAgents.length)
368
+ } else if (event.key === 'ArrowUp') {
369
+ event.preventDefault()
370
+ setHighlight((current) => (current - 1 + filteredAgents.length) % filteredAgents.length)
371
+ } else if (event.key === 'Enter') {
372
+ event.preventDefault()
373
+ const target = filteredAgents[highlight] ?? filteredAgents[0]
374
+ if (target) handleSelectAgent(target)
375
+ } else if (event.key === 'Escape') {
376
+ setPickerOpen(false)
377
+ }
378
+ },
379
+ [aiConfigured, filteredAgents, handleSelectAgent, highlight],
380
+ )
381
+
382
+ const triggerLabel = t('ai_assistant.launcher.triggerAriaLabel', 'Open AI assistant')
383
+ const dialogTitle = t('ai_assistant.launcher.dialogTitle', 'AI assistants')
384
+ const dialogDescription = t(
385
+ 'ai_assistant.launcher.dialogDescription',
386
+ 'Pick an assistant. Use ↑/↓ to navigate, Enter to launch, Esc to close.',
387
+ )
388
+ const placeholder = t('ai_assistant.launcher.searchPlaceholder', 'Search assistants...')
389
+ const emptyText = t('ai_assistant.launcher.empty', 'No assistants match your search.')
390
+ const noneText = t(
391
+ 'ai_assistant.launcher.none',
392
+ 'No assistants are available for your account.',
393
+ )
394
+ const writesBadge = t('ai_assistant.launcher.writesBadge', 'Can write')
395
+
396
+ const launcherSuggestions = React.useMemo<AiChatSuggestion[]>(
397
+ () => {
398
+ const generic: AiChatSuggestion[] = [
399
+ {
400
+ label: t('ai_assistant.launcher.welcome.suggestion1', 'What can you help me with?'),
401
+ prompt: 'What can you help me with on this tenant?',
402
+ icon: <Sparkles className="size-4" />,
403
+ },
404
+ {
405
+ label: t('ai_assistant.launcher.welcome.suggestion2', 'Show what data you can access'),
406
+ prompt: 'Describe the data you can read for this tenant — entities, fields, and limits.',
407
+ icon: <Bot className="size-4" />,
408
+ },
409
+ {
410
+ label: t('ai_assistant.launcher.welcome.suggestion3', 'Suggest things to try'),
411
+ prompt:
412
+ 'Suggest five concrete questions I could ask you that would surface useful insights for this tenant.',
413
+ icon: <Lightbulb className="size-4" />,
414
+ },
415
+ {
416
+ label: t('ai_assistant.launcher.welcome.suggestion4', 'How do I use this assistant?'),
417
+ prompt:
418
+ 'Walk me through how to use this assistant: when to ask, what tools you call, and how confirmations work.',
419
+ icon: <HelpCircle className="size-4" />,
420
+ },
421
+ ]
422
+ return [...(activeAgent?.suggestions ?? []), ...generic]
423
+ },
424
+ [t, activeAgent],
425
+ )
426
+
427
+ // The launcher is page-agnostic — it has no record-level context to pin.
428
+ // Context chips render only when records are actually attached (selection,
429
+ // file uploads, etc.); a chip showing just the agent's name is redundant
430
+ // because the agent is already named in the dialog/dock header.
431
+ const launcherContextItems = React.useMemo<AiChatContextItem[]>(() => [], [])
432
+
433
+ // Hide the launcher entirely when no agents are accessible. If agents exist
434
+ // but providers are not configured, still render the trigger and show a
435
+ // setup prompt inside the picker.
436
+ // Treat `healthy === false` (an explicit `{ healthy: false }` body) as a
437
+ // hard veto so we still hide when the runtime explicitly opts out, but
438
+ // unknown / pending health does NOT block the agents fetch result.
439
+ const shouldRender =
440
+ healthy !== false &&
441
+ agentsLoaded &&
442
+ agents.length > 0
443
+
444
+ if (!shouldRender) return null
445
+
446
+ const shortLabel = t('ai_assistant.launcher.triggerLabel', 'AI')
447
+
448
+ return (
449
+ <>
450
+ {/* Desktop: rounded rectangle pill matching the global-search trigger
451
+ (variant=ghost, size=sm, icon + label + ⌘L kbd hint). */}
452
+ <Button
453
+ type="button"
454
+ variant="ghost"
455
+ size="sm"
456
+ onClick={openPicker}
457
+ className={cn('hidden sm:inline-flex items-center gap-2', className)}
458
+ data-ai-launcher-trigger=""
459
+ aria-label={triggerLabel}
460
+ title={triggerLabel}
461
+ >
462
+ <Sparkles className="size-4" aria-hidden />
463
+ <span>{shortLabel}</span>
464
+ <span className="ml-2 rounded border px-1 text-xs text-muted-foreground">
465
+ ⌘L
466
+ </span>
467
+ </Button>
468
+ {/* Mobile fallback: icon-only button — same pattern as global search. */}
469
+ <IconButton
470
+ type="button"
471
+ variant="ghost"
472
+ size="sm"
473
+ className="sm:hidden"
474
+ onClick={openPicker}
475
+ aria-label={triggerLabel}
476
+ data-ai-launcher-trigger-mobile=""
477
+ >
478
+ <Sparkles className="size-4" aria-hidden />
479
+ </IconButton>
480
+ <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
481
+ <DialogContent
482
+ className="sm:max-w-lg p-0 gap-0 overflow-hidden"
483
+ data-ai-launcher-picker=""
484
+ onKeyDown={handlePickerKeyDown}
485
+ >
486
+ <DialogHeader className="px-4 pt-4 pb-2">
487
+ <DialogTitle className="flex items-center gap-2 text-base">
488
+ <Sparkles className="size-4 text-primary" aria-hidden />
489
+ {dialogTitle}
490
+ </DialogTitle>
491
+ <DialogDescription className="text-xs">
492
+ {dialogDescription}
493
+ </DialogDescription>
494
+ </DialogHeader>
495
+ <div className="border-y border-border bg-muted/30 px-3 py-2">
496
+ <div className="relative">
497
+ <Search
498
+ className="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
499
+ aria-hidden
500
+ />
501
+ <input
502
+ autoFocus
503
+ type="text"
504
+ value={query}
505
+ onChange={(event) => {
506
+ setQuery(event.target.value)
507
+ setHighlight(0)
508
+ }}
509
+ placeholder={placeholder}
510
+ className="w-full rounded-md border border-input bg-background px-8 py-1.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40"
511
+ data-ai-launcher-search-input=""
512
+ />
513
+ </div>
514
+ </div>
515
+ <div
516
+ className="max-h-80 overflow-y-auto py-1"
517
+ data-ai-launcher-list=""
518
+ role="listbox"
519
+ aria-label={dialogTitle}
520
+ >
521
+ {aiConfigured === false ? (
522
+ <AiProviderSetupPanel t={t} />
523
+ ) : filteredAgents.length === 0 ? (
524
+ <div className="px-4 py-6 text-center text-xs text-muted-foreground">
525
+ {agents.length === 0 ? noneText : emptyText}
526
+ </div>
527
+ ) : (
528
+ filteredAgents.map((agent, index) => {
529
+ const isActive = index === highlight
530
+ const writes = isMutationCapable(agent.mutationPolicy)
531
+ return (
532
+ <button
533
+ key={agent.id}
534
+ type="button"
535
+ role="option"
536
+ aria-selected={isActive}
537
+ onMouseEnter={() => setHighlight(index)}
538
+ onClick={() => handleSelectAgent(agent)}
539
+ data-ai-launcher-agent-id={agent.id}
540
+ data-active={isActive ? 'true' : 'false'}
541
+ className={cn(
542
+ 'flex w-full items-start gap-3 px-3 py-2 text-left text-sm transition-colors',
543
+ isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60',
544
+ )}
545
+ >
546
+ <span className="mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
547
+ <Sparkles className="size-3.5" aria-hidden />
548
+ </span>
549
+ <span className="flex-1 min-w-0 space-y-0.5">
550
+ <span className="flex items-center gap-2">
551
+ <span className="truncate font-medium leading-tight">
552
+ {agent.label}
553
+ </span>
554
+ <span
555
+ className="inline-flex items-center rounded-full border border-border bg-secondary px-1.5 py-0 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground"
556
+ data-ai-beta-chip=""
557
+ >
558
+ {t('ai_assistant.chat.betaChip', 'beta')}
559
+ </span>
560
+ {writes ? (
561
+ <span
562
+ className="inline-flex items-center rounded-full border border-border bg-secondary px-1.5 py-0 text-[10px] font-medium text-secondary-foreground"
563
+ data-ai-launcher-writes=""
564
+ >
565
+ {writesBadge}
566
+ </span>
567
+ ) : null}
568
+ </span>
569
+ {agent.description ? (
570
+ <span className="block truncate text-xs text-muted-foreground">
571
+ {agent.description}
572
+ </span>
573
+ ) : null}
574
+ <span className="block truncate font-mono text-[10px] text-muted-foreground/80">
575
+ {agent.id}
576
+ </span>
577
+ </span>
578
+ </button>
579
+ )
580
+ })
581
+ )}
582
+ </div>
583
+ <div className="flex items-center justify-between gap-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
584
+ <span className="flex items-center gap-2">
585
+ <KbdShortcut keys={['↑', '↓']} />{' '}
586
+ {t('ai_assistant.launcher.hint.navigate', 'Navigate')}
587
+ <span className="mx-1 text-border">·</span>
588
+ <Kbd>Enter</Kbd> {t('ai_assistant.launcher.hint.launch', 'Launch')}
589
+ <span className="mx-1 text-border">·</span>
590
+ <Kbd>Esc</Kbd> {t('ai_assistant.launcher.hint.close', 'Close')}
591
+ </span>
592
+ <span className="hidden sm:inline-flex items-center gap-1">
593
+ <KbdShortcut keys={['⌘', 'L']} />
594
+ </span>
595
+ </div>
596
+ {agentsError ? (
597
+ <div
598
+ className="border-t border-status-error-border bg-status-error-bg px-3 py-1.5 text-[11px] text-status-error-foreground"
599
+ data-ai-launcher-error=""
600
+ >
601
+ <Loader2 className="mr-1 inline size-3 animate-spin" aria-hidden />
602
+ {agentsError}
603
+ </div>
604
+ ) : null}
605
+ </DialogContent>
606
+ </Dialog>
607
+ <Dialog open={chatOpen} onOpenChange={setChatOpen}>
608
+ <DialogContent
609
+ className={cn(
610
+ // Mobile: full-screen sheet (matches per-page assistant
611
+ // triggers). Desktop (≥sm): right-anchored side sheet so the
612
+ // chat doesn't appear randomly cropped or off-center.
613
+ // The Dialog primitive applies a centering transform at the
614
+ // sm breakpoint (`sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2
615
+ // sm:-translate-y-1/2 sm:inset-auto`); each piece must be
616
+ // overridden at the same breakpoint or the panel renders half
617
+ // off the viewport on the left.
618
+ 'top-0 left-0 right-0 bottom-0 translate-x-0 translate-y-0 max-w-none w-screen h-svh max-h-svh rounded-none',
619
+ 'sm:top-0 sm:bottom-0 sm:right-0 sm:left-auto sm:translate-x-0 sm:translate-y-0',
620
+ 'sm:max-w-xl sm:w-[36rem] sm:rounded-l-2xl sm:h-screen sm:max-h-screen',
621
+ 'flex flex-col gap-3 p-4 z-[70]',
622
+ )}
623
+ data-ai-launcher-sheet=""
624
+ >
625
+ <DialogHeader>
626
+ <div className="flex items-center gap-3 pr-8">
627
+ {/* Dock button on the LEFT to avoid colliding with the
628
+ Dialog primitive's auto-rendered X close button in the
629
+ top-right corner. Desktop-only — the side dock panel
630
+ itself is hidden on mobile. */}
631
+ <IconButton
632
+ type="button"
633
+ variant="ghost"
634
+ size="sm"
635
+ aria-label={t('ai_assistant.launcher.sheet.dock', 'Dock to side')}
636
+ title={t('ai_assistant.launcher.sheet.dock', 'Dock to side')}
637
+ onClick={() => {
638
+ if (!activeAgent) return
639
+ dock.dock({
640
+ agent: activeAgent.id,
641
+ label: activeAgent.label,
642
+ description:
643
+ activeAgent.moduleId ??
644
+ t('ai_assistant.launcher.dock.subtitle', 'AI assistant'),
645
+ pageContext: {},
646
+ placeholder: t(
647
+ 'ai_assistant.launcher.composerPlaceholder',
648
+ 'Ask anything…',
649
+ ),
650
+ suggestions: launcherSuggestions,
651
+ contextItems: launcherContextItems,
652
+ welcomeTitle: activeAgent.label,
653
+ welcomeDescription:
654
+ activeAgent.description ??
655
+ t(
656
+ 'ai_assistant.launcher.welcome.fallback',
657
+ 'How can I help?',
658
+ ),
659
+ })
660
+ setChatOpen(false)
661
+ }}
662
+ data-ai-launcher-dock=""
663
+ className="hidden lg:inline-flex shrink-0"
664
+ >
665
+ <PanelRightOpen className="size-4" aria-hidden />
666
+ </IconButton>
667
+ <DialogTitle className="flex-1 min-w-0 flex items-center gap-2">
668
+ <Sparkles className="size-4 text-primary shrink-0" aria-hidden />
669
+ <span className="min-w-0 truncate">{activeAgent?.label ?? dialogTitle}</span>
670
+ <span
671
+ className="inline-flex shrink-0 items-center rounded-full border border-border bg-secondary px-1.5 py-0 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground"
672
+ data-ai-beta-chip=""
673
+ >
674
+ {t('ai_assistant.chat.betaChip', 'beta')}
675
+ </span>
676
+ </DialogTitle>
677
+ </div>
678
+ {activeAgent?.description ? (
679
+ <DialogDescription>{activeAgent.description}</DialogDescription>
680
+ ) : null}
681
+ </DialogHeader>
682
+ {activeAgent ? (
683
+ <LauncherChatBody
684
+ activeAgent={activeAgent}
685
+ suggestions={launcherSuggestions}
686
+ contextItems={launcherContextItems}
687
+ welcomeFallback={t(
688
+ 'ai_assistant.launcher.welcome.fallback',
689
+ 'How can I help?',
690
+ )}
691
+ placeholder={t(
692
+ 'ai_assistant.launcher.composerPlaceholder',
693
+ 'Ask anything…',
694
+ )}
695
+ />
696
+ ) : null}
697
+ </DialogContent>
698
+ </Dialog>
699
+ </>
700
+ )
701
+ }
702
+
703
+ interface LauncherChatBodyProps {
704
+ activeAgent: AiAssistantLauncherAgent
705
+ suggestions: AiChatSuggestion[]
706
+ contextItems: AiChatContextItem[]
707
+ welcomeFallback: string
708
+ placeholder: string
709
+ }
710
+
711
+ function LauncherChatBody({
712
+ activeAgent,
713
+ suggestions,
714
+ contextItems,
715
+ welcomeFallback,
716
+ placeholder,
717
+ }: LauncherChatBodyProps) {
718
+ const sessions = useAiChatSessions()
719
+ const session = sessions.getActiveSession(activeAgent.id)
720
+
721
+ React.useEffect(() => {
722
+ if (!session) sessions.ensureSession(activeAgent.id)
723
+ }, [activeAgent.id, session, sessions])
724
+
725
+ return (
726
+ <>
727
+ <ChatPaneTabs agentId={activeAgent.id} className="border-b" />
728
+ <div className="min-h-0 flex-1" data-ai-launcher-chat-container="">
729
+ {session ? (
730
+ <React.Suspense fallback={null}>
731
+ <LazyAiChat
732
+ key={session.id}
733
+ agent={activeAgent.id}
734
+ conversationId={session.conversationId}
735
+ pageContext={{}}
736
+ className="h-full"
737
+ placeholder={placeholder}
738
+ suggestions={suggestions}
739
+ contextItems={contextItems}
740
+ welcomeTitle={activeAgent.label}
741
+ welcomeDescription={activeAgent.description ?? welcomeFallback}
742
+ />
743
+ </React.Suspense>
744
+ ) : null}
745
+ </div>
746
+ </>
747
+ )
748
+ }
749
+
750
+ export default AiAssistantLauncher
751
+
752
+ interface AiProviderSetupPanelProps {
753
+ t: ReturnType<typeof useT>
754
+ }
755
+
756
+ function AiProviderSetupPanel({ t }: AiProviderSetupPanelProps) {
757
+ return (
758
+ <div className="px-4 py-5" data-ai-launcher-provider-setup="">
759
+ <div className="rounded-lg border border-status-warning-border bg-status-warning-bg p-4 text-status-warning-text">
760
+ <div className="flex items-start gap-3">
761
+ <span className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-background/70 text-status-warning-icon">
762
+ <AlertTriangle className="size-4" aria-hidden />
763
+ </span>
764
+ <div className="min-w-0 space-y-3">
765
+ <div>
766
+ <h3 className="text-sm font-semibold">
767
+ {t('ai_assistant.launcher.setup.title', 'Configure an AI provider to use assistants')}
768
+ </h3>
769
+ <p className="mt-1 text-xs leading-5 text-status-warning-text/90">
770
+ {t(
771
+ 'ai_assistant.launcher.setup.body',
772
+ 'AI assistants are installed, but no provider key is configured. Set OPENCODE_PROVIDER and one matching API key in your .env file, then restart the app.',
773
+ )}
774
+ </p>
775
+ </div>
776
+ <div className="rounded-md border border-status-warning-border/70 bg-background/80 p-3 font-mono text-[11px] leading-5 text-foreground">
777
+ <div>OPENCODE_PROVIDER=anthropic</div>
778
+ <div>ANTHROPIC_API_KEY=...</div>
779
+ <div className="mt-2 text-muted-foreground"># or</div>
780
+ <div>OPENCODE_PROVIDER=openai</div>
781
+ <div>OPENAI_API_KEY=...</div>
782
+ <div className="mt-2 text-muted-foreground"># or</div>
783
+ <div>OPENCODE_PROVIDER=google</div>
784
+ <div>GOOGLE_GENERATIVE_AI_API_KEY=...</div>
785
+ </div>
786
+ <div className="flex flex-wrap gap-2">
787
+ <Button asChild size="sm" variant="outline">
788
+ <a href={AI_ASSISTANT_DOCS_URL} target="_blank" rel="noreferrer">
789
+ {t('ai_assistant.launcher.setup.docs', 'AI assistant docs')}
790
+ <ExternalLink className="ml-1 size-3" aria-hidden />
791
+ </a>
792
+ </Button>
793
+ <Button asChild size="sm" variant="ghost">
794
+ <a href={AI_ASSISTANT_SETTINGS_DOCS_URL} target="_blank" rel="noreferrer">
795
+ {t('ai_assistant.launcher.setup.settingsDocs', 'Provider settings')}
796
+ <ExternalLink className="ml-1 size-3" aria-hidden />
797
+ </a>
798
+ </Button>
799
+ </div>
800
+ </div>
801
+ </div>
802
+ </div>
803
+ </div>
804
+ )
805
+ }