@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,180 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ uploadAttachmentsForChat,
6
+ type UploadAttachmentsForChatOptions,
7
+ type UploadAttachmentsForChatResult,
8
+ type UploadFailureReason,
9
+ } from './upload-adapter'
10
+
11
+ /**
12
+ * React hook wrapping {@link uploadAttachmentsForChat} with per-file state so
13
+ * the {@link AiChat} composer can render progress chips, error badges, and a
14
+ * Clear action without each consumer re-implementing the machinery.
15
+ *
16
+ * The hook is DS-neutral: it exposes only state and {@link UploadFailureReason}
17
+ * codes. Consumers translate user-facing strings through `useT()` at render
18
+ * time — no hard-coded copy in the hook.
19
+ */
20
+
21
+ export interface UseAiChatUploadOptions extends UploadAttachmentsForChatOptions {
22
+ /** Identical to the adapter options; forwarded verbatim. */
23
+ }
24
+
25
+ export type AiChatUploadFileStatus = 'queued' | 'uploading' | 'done' | 'error'
26
+
27
+ export interface AiChatUploadFileState {
28
+ fileName: string
29
+ size: number
30
+ progress: number
31
+ status: AiChatUploadFileStatus
32
+ attachmentId?: string
33
+ reason?: UploadFailureReason
34
+ error?: string
35
+ }
36
+
37
+ export interface UseAiChatUploadState {
38
+ files: AiChatUploadFileState[]
39
+ overallProgress: number
40
+ busy: boolean
41
+ upload: (files: File[]) => Promise<UploadAttachmentsForChatResult>
42
+ reset: () => void
43
+ }
44
+
45
+ const EMPTY_STATE: AiChatUploadFileState[] = []
46
+
47
+ function computeOverallProgress(entries: AiChatUploadFileState[]): number {
48
+ if (entries.length === 0) return 0
49
+ const total = entries.reduce((sum, entry) => sum + entry.progress, 0)
50
+ const average = total / entries.length
51
+ if (!Number.isFinite(average)) return 0
52
+ if (average < 0) return 0
53
+ if (average > 1) return 1
54
+ return average
55
+ }
56
+
57
+ export function useAiChatUpload(
58
+ options: UseAiChatUploadOptions = {},
59
+ ): UseAiChatUploadState {
60
+ const [files, setFiles] = React.useState<AiChatUploadFileState[]>(EMPTY_STATE)
61
+ const [busy, setBusy] = React.useState(false)
62
+ const optionsRef = React.useRef(options)
63
+ React.useEffect(() => {
64
+ optionsRef.current = options
65
+ }, [options])
66
+
67
+ const overallProgress = React.useMemo(() => computeOverallProgress(files), [files])
68
+
69
+ const reset = React.useCallback(() => {
70
+ setFiles(EMPTY_STATE)
71
+ setBusy(false)
72
+ }, [])
73
+
74
+ const upload = React.useCallback(
75
+ async (incoming: File[]): Promise<UploadAttachmentsForChatResult> => {
76
+ if (!incoming || incoming.length === 0) {
77
+ return { items: [], failed: [] }
78
+ }
79
+ const initialEntries: AiChatUploadFileState[] = incoming.map((file) => ({
80
+ fileName: file.name,
81
+ size: file.size,
82
+ progress: 0,
83
+ status: 'uploading',
84
+ }))
85
+ setFiles(initialEntries)
86
+ setBusy(true)
87
+
88
+ const callerOptions = optionsRef.current
89
+ const callerProgress = callerOptions.onProgress
90
+ const result = await uploadAttachmentsForChat(incoming, {
91
+ ...callerOptions,
92
+ onProgress: (fileIndex, progress) => {
93
+ const ratio =
94
+ progress.total > 0
95
+ ? Math.max(0, Math.min(1, progress.loaded / progress.total))
96
+ : 0
97
+ setFiles((current) => {
98
+ if (fileIndex < 0 || fileIndex >= current.length) return current
99
+ const next = current.slice()
100
+ const entry = next[fileIndex]
101
+ if (!entry) return current
102
+ next[fileIndex] = { ...entry, progress: ratio }
103
+ return next
104
+ })
105
+ if (callerProgress) {
106
+ try {
107
+ callerProgress(fileIndex, progress)
108
+ } catch {
109
+ // Consumer-supplied callbacks must never abort state updates.
110
+ }
111
+ }
112
+ },
113
+ }).catch((err) => {
114
+ // uploadAttachmentsForChat only rejects on programming errors; coerce
115
+ // to a failure envelope so the hook state never throws at consumers.
116
+ const message = err instanceof Error ? err.message : 'Upload batch failed.'
117
+ return {
118
+ items: [],
119
+ failed: incoming.map((file, inputIndex) => ({
120
+ fileName: file.name,
121
+ originalFileName: file.name,
122
+ inputIndex,
123
+ reason: 'network' as UploadFailureReason,
124
+ message,
125
+ })),
126
+ } satisfies UploadAttachmentsForChatResult
127
+ })
128
+
129
+ setFiles((current) => {
130
+ const failedByName = new Map<string, typeof result.failed[number]>()
131
+ for (const failure of result.failed) {
132
+ if (!failedByName.has(failure.fileName)) {
133
+ failedByName.set(failure.fileName, failure)
134
+ }
135
+ }
136
+ return current.map((entry, index) => {
137
+ const success = result.items.find(
138
+ (item, itemIndex) => itemIndex === index && item.fileName === entry.fileName,
139
+ )
140
+ if (success) {
141
+ return {
142
+ ...entry,
143
+ progress: 1,
144
+ status: 'done' as AiChatUploadFileStatus,
145
+ attachmentId: success.attachmentId,
146
+ }
147
+ }
148
+ const failure = failedByName.get(entry.fileName)
149
+ if (failure) {
150
+ failedByName.delete(entry.fileName)
151
+ return {
152
+ ...entry,
153
+ status: 'error' as AiChatUploadFileStatus,
154
+ reason: failure.reason,
155
+ error: failure.message,
156
+ }
157
+ }
158
+ // Defensive: a worker exited without producing either outcome.
159
+ return {
160
+ ...entry,
161
+ status: 'error' as AiChatUploadFileStatus,
162
+ reason: 'network' as UploadFailureReason,
163
+ }
164
+ })
165
+ })
166
+
167
+ setBusy(false)
168
+ return result
169
+ },
170
+ [],
171
+ )
172
+
173
+ return {
174
+ files,
175
+ overallProgress,
176
+ busy,
177
+ upload,
178
+ reset,
179
+ }
180
+ }
@@ -0,0 +1,79 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ /**
6
+ * Shared keyboard-shortcut hook for the AI surfaces shipped in Phase 2
7
+ * (Step 4.6 / Phase 2 WS-B polish). Centralises the `Cmd/Ctrl+Enter` and
8
+ * `Escape` handling used by `<AiChat>`, the AI playground, and the agent
9
+ * settings page so every surface honours the same shortcuts without each
10
+ * page rolling its own listener.
11
+ *
12
+ * - `onSubmit` fires on `Enter` (without `Shift`) when the shortcut is
13
+ * triggered while focus is inside the bound element. `Shift+Enter` is
14
+ * left to the browser for native newline insertion.
15
+ * - `onCancel` fires on `Escape`. Callers decide what cancel means (abort an
16
+ * in-flight stream, blur the composer, close a drawer, reset a draft).
17
+ * - `enabled` gates the hook for conditional bindings without unmounting.
18
+ *
19
+ * The hook is deliberately minimal. It never stops propagation; callers that
20
+ * embed modal dialogs keep their own Escape handling because React events
21
+ * bubble predictably.
22
+ */
23
+ export interface UseAiShortcutsOptions {
24
+ onSubmit?: () => void
25
+ onCancel?: () => void
26
+ enabled?: boolean
27
+ }
28
+
29
+ export interface UseAiShortcutsResult {
30
+ /**
31
+ * Keyboard handler ready to be attached via `onKeyDown`. Returns `true`
32
+ * when the event matched a shortcut so callers can branch on the result.
33
+ */
34
+ handleKeyDown: (event: React.KeyboardEvent) => boolean
35
+ }
36
+
37
+ export function useAiShortcuts(options: UseAiShortcutsOptions): UseAiShortcutsResult {
38
+ const { onSubmit, onCancel, enabled = true } = options
39
+
40
+ const onSubmitRef = React.useRef(onSubmit)
41
+ const onCancelRef = React.useRef(onCancel)
42
+ React.useEffect(() => {
43
+ onSubmitRef.current = onSubmit
44
+ }, [onSubmit])
45
+ React.useEffect(() => {
46
+ onCancelRef.current = onCancel
47
+ }, [onCancel])
48
+
49
+ const handleKeyDown = React.useCallback<UseAiShortcutsResult['handleKeyDown']>(
50
+ (event) => {
51
+ if (!enabled) return false
52
+ // Enter — primary submit. Shift+Enter inserts a newline instead.
53
+ if (event.key === 'Enter' && !event.shiftKey) {
54
+ if (onSubmitRef.current) {
55
+ event.preventDefault()
56
+ onSubmitRef.current()
57
+ return true
58
+ }
59
+ return false
60
+ }
61
+ // Escape — secondary cancel. Never swallow unless a handler is bound so
62
+ // parent dialogs can still handle Escape the native way.
63
+ if (event.key === 'Escape') {
64
+ if (onCancelRef.current) {
65
+ event.preventDefault()
66
+ onCancelRef.current()
67
+ return true
68
+ }
69
+ return false
70
+ }
71
+ return false
72
+ },
73
+ [enabled],
74
+ )
75
+
76
+ return { handleKeyDown }
77
+ }
78
+
79
+ export default useAiShortcuts
@@ -30,6 +30,9 @@ import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
30
30
  import { useEventBridge } from './injection/eventBridge'
31
31
  import { StatusBadgeInjectionSpot } from './injection/StatusBadgeInjectionSpot'
32
32
  import { UmesDevToolsPanel } from './devtools'
33
+ import { AiDockProvider } from '../ai/AiDock'
34
+ import { AiChatSessionsProvider } from '../ai/AiChatSessions'
35
+ import { AiAssistantLauncher } from '../ai/AiAssistantLauncher'
33
36
  import { BackendChromeProvider, useBackendChrome } from './BackendChromeProvider'
34
37
  import {
35
38
  BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID,
@@ -393,7 +396,11 @@ export function AppShell(props: AppShellProps) {
393
396
  return (
394
397
  <QueryProvider>
395
398
  <BackendChromeProvider adminNavApi={props.adminNavApi}>
396
- <AppShellBody {...props} />
399
+ <AiChatSessionsProvider>
400
+ <AiDockProvider>
401
+ <AppShellBody {...props} />
402
+ </AiDockProvider>
403
+ </AiChatSessionsProvider>
397
404
  </BackendChromeProvider>
398
405
  </QueryProvider>
399
406
  )
@@ -464,11 +471,11 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
464
471
  }
465
472
  update()
466
473
  target.addEventListener('scroll', update, { passive: true })
467
- const ro = new ResizeObserver(update)
468
- ro.observe(target)
474
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null
475
+ ro?.observe(target)
469
476
  return () => {
470
477
  target.removeEventListener('scroll', update)
471
- ro.disconnect()
478
+ ro?.disconnect()
472
479
  }
473
480
  // eslint-disable-next-line react-hooks/exhaustive-deps
474
481
  }, [pathname, effectiveCollapsed])
@@ -1229,6 +1236,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1229
1236
  context={injectionContext}
1230
1237
  />
1231
1238
  {renderedTopbarInjectedActions}
1239
+ <AiAssistantLauncher variant="topbar" />
1232
1240
  {rightHeaderSlot ? (
1233
1241
  rightHeaderSlot
1234
1242
  ) : (
@@ -1298,4 +1306,3 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1298
1306
  </HeaderContext.Provider>
1299
1307
  )
1300
1308
  }
1301
-
@@ -41,6 +41,8 @@ export function BackendChromeProvider({ adminNavApi, children }: BackendChromePr
41
41
  const nextPayload = call.result
42
42
  chromeCache.set(buildCacheKey(adminNavApi), nextPayload)
43
43
  setPayload(nextPayload)
44
+ } catch {
45
+ return
44
46
  } finally {
45
47
  setIsLoading(false)
46
48
  }
@@ -1039,7 +1039,7 @@ export function DataTable<T>({
1039
1039
  }, [injectionSpotId, perspective?.tableId])
1040
1040
  const resolvedInjectionSpotId = injectionSpotId ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
1041
1041
  const resolvedReplacementHandle = replacementHandle ?? ComponentReplacementHandles.dataTable(extensionTableId ?? 'unknown')
1042
- const resolvedInjectionContext = React.useMemo(
1042
+ const baseInjectionContext = React.useMemo(
1043
1043
  () => injectionContext ?? { tableId: perspective?.tableId ?? null, title: typeof title === 'string' ? title : undefined },
1044
1044
  [injectionContext, perspective?.tableId, title]
1045
1045
  )
@@ -1051,6 +1051,10 @@ export function DataTable<T>({
1051
1051
  () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:toolbar` : null),
1052
1052
  [resolvedInjectionSpotId]
1053
1053
  )
1054
+ const searchTrailingInjectionSpotId = React.useMemo(
1055
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:search-trailing` : null),
1056
+ [resolvedInjectionSpotId]
1057
+ )
1054
1058
  const footerInjectionSpotId = React.useMemo(
1055
1059
  () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:footer` : null),
1056
1060
  [resolvedInjectionSpotId]
@@ -1340,6 +1344,15 @@ export function DataTable<T>({
1340
1344
  if (Object.keys(rowSelection).length === 0) return
1341
1345
  setRowSelection({})
1342
1346
  }, [hasInjectedBulkActions, rowSelection])
1347
+ const resolvedInjectionContext = React.useMemo(
1348
+ () => {
1349
+ if (!hasInjectedBulkActions) return baseInjectionContext
1350
+ const selectedIds = Object.keys(rowSelection).filter((key) => rowSelection[key])
1351
+ if (selectedIds.length === 0) return baseInjectionContext
1352
+ return { ...baseInjectionContext, _selectedRowIds: selectedIds, _selectedCount: selectedIds.length }
1353
+ },
1354
+ [baseInjectionContext, hasInjectedBulkActions, rowSelection],
1355
+ )
1343
1356
  React.useEffect(() => {
1344
1357
  const ids = table.getAllLeafColumns().map((column) => column.id)
1345
1358
  if (!ids.length) return
@@ -2180,6 +2193,9 @@ export function DataTable<T>({
2180
2193
  }) : null}
2181
2194
  </div>
2182
2195
  ) : null
2196
+ const searchTrailingNode = searchTrailingInjectionSpotId && onSearchChange ? (
2197
+ <InjectionSpot spotId={searchTrailingInjectionSpotId} context={resolvedInjectionContext} />
2198
+ ) : null
2183
2199
  return (
2184
2200
  <FilterBar
2185
2201
  searchValue={searchValue}
@@ -2192,6 +2208,7 @@ export function DataTable<T>({
2192
2208
  onClear={onFiltersClear}
2193
2209
  leadingItems={leadingItems}
2194
2210
  trailingItems={trailingItems}
2211
+ searchTrailing={searchTrailingNode}
2195
2212
  filtersExtraContent={fieldsetSelector}
2196
2213
  layout={embedded ? 'inline' : 'stacked'}
2197
2214
  className={embedded ? 'min-h-[2.25rem]' : undefined}
@@ -2224,6 +2241,8 @@ export function DataTable<T>({
2224
2241
  selectedRows,
2225
2242
  runBulkAction,
2226
2243
  runPropBulkAction,
2244
+ searchTrailingInjectionSpotId,
2245
+ resolvedInjectionContext,
2227
2246
  ])
2228
2247
 
2229
2248
  const hasTitle = title != null
@@ -17,6 +17,13 @@ export type FilterBarProps = {
17
17
  className?: string
18
18
  leadingItems?: React.ReactNode
19
19
  trailingItems?: React.ReactNode
20
+ /**
21
+ * Items rendered immediately after the search input on the same row.
22
+ * Intended for compact, icon-sized triggers (AI assistants, saved view
23
+ * shortcuts). Stays adjacent to the search input regardless of
24
+ * `searchAlign` and is suppressed when no search input is rendered.
25
+ */
26
+ searchTrailing?: React.ReactNode
20
27
  layout?: 'stacked' | 'inline'
21
28
  filtersExtraContent?: React.ReactNode
22
29
  }
@@ -33,6 +40,7 @@ export function FilterBar({
33
40
  className,
34
41
  leadingItems,
35
42
  trailingItems,
43
+ searchTrailing,
36
44
  layout = 'stacked',
37
45
  filtersExtraContent,
38
46
  }: FilterBarProps) {
@@ -72,20 +80,25 @@ export function FilterBar({
72
80
  }, [values])
73
81
 
74
82
  const containerClass = `flex flex-col ${layout === 'inline' ? 'gap-1 sm:gap-2' : 'gap-2'} w-full`
75
- const searchInput = onSearchChange ? (
76
- <div className={`relative w-full sm:w-72 lg:w-80 ${searchAlign === 'right' ? 'sm:ml-auto' : ''}`}>
77
- <input
78
- value={searchDraft}
79
- onChange={(e) => setSearchDraft(e.target.value)}
80
- placeholder={resolvedSearchPlaceholder}
81
- className="h-9 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50"
82
- suppressHydrationWarning
83
- />
84
- <Search aria-hidden="true" className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
83
+ const searchBlock = onSearchChange ? (
84
+ <div className={`flex items-center gap-2 ${searchAlign === 'right' ? 'sm:ml-auto' : ''}`}>
85
+ <div className="relative w-full sm:w-72 lg:w-80">
86
+ <input
87
+ value={searchDraft}
88
+ onChange={(e) => setSearchDraft(e.target.value)}
89
+ placeholder={resolvedSearchPlaceholder}
90
+ className="h-9 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50"
91
+ suppressHydrationWarning
92
+ />
93
+ <Search aria-hidden="true" className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
94
+ </div>
95
+ {searchTrailing ? (
96
+ <div className="flex items-center gap-1">{searchTrailing}</div>
97
+ ) : null}
85
98
  </div>
86
99
  ) : null
87
100
  const controls = (
88
- <div className={`flex flex-wrap items-center gap-2 ${searchAlign === 'left' && searchInput ? 'sm:ml-auto' : ''}`}>
101
+ <div className={`flex flex-wrap items-center gap-2 ${searchAlign === 'left' && searchBlock ? 'sm:ml-auto' : ''}`}>
89
102
  {filters.length > 0 && (
90
103
  <Button variant="outline" onClick={() => setOpen(true)}>
91
104
  <ListFilter aria-hidden="true" className="size-4 opacity-80" />
@@ -103,9 +116,9 @@ export function FilterBar({
103
116
  return (
104
117
  <div className={`${containerClass} ${className ?? ''}`}>
105
118
  <div className="flex flex-wrap items-center gap-2 w-full">
106
- {searchAlign === 'left' ? searchInput : null}
119
+ {searchAlign === 'left' ? searchBlock : null}
107
120
  {controls}
108
- {searchAlign === 'right' ? searchInput : null}
121
+ {searchAlign === 'right' ? searchBlock : null}
109
122
  </div>
110
123
  {/* Active filter chips */}
111
124
  {filters.length > 0 && activeCount > 0 && (
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { render, screen, waitFor } from '@testing-library/react'
7
+ import { BackendChromeProvider, useBackendChrome } from '../BackendChromeProvider'
8
+ import { apiCall } from '../utils/apiCall'
9
+
10
+ jest.mock('../utils/apiCall', () => ({
11
+ apiCall: jest.fn(),
12
+ }))
13
+
14
+ function ChromeStateProbe() {
15
+ const chrome = useBackendChrome()
16
+ return (
17
+ <div>
18
+ <span data-testid="loading">{chrome.isLoading ? 'loading' : 'idle'}</span>
19
+ <span data-testid="ready">{chrome.isReady ? 'ready' : 'not-ready'}</span>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ describe('BackendChromeProvider', () => {
25
+ beforeEach(() => {
26
+ ;(apiCall as jest.Mock).mockReset()
27
+ })
28
+
29
+ it('contains transient navigation fetch failures', async () => {
30
+ ;(apiCall as jest.Mock).mockRejectedValue(new TypeError('Failed to fetch'))
31
+
32
+ render(
33
+ <BackendChromeProvider adminNavApi="/api/auth/admin/nav">
34
+ <ChromeStateProbe />
35
+ </BackendChromeProvider>,
36
+ )
37
+
38
+ await waitFor(() => {
39
+ expect(screen.getByTestId('loading')).toHaveTextContent('idle')
40
+ })
41
+
42
+ expect(screen.getByTestId('ready')).toHaveTextContent('not-ready')
43
+ expect(apiCall).toHaveBeenCalledWith('/api/auth/admin/nav', { credentials: 'include' })
44
+ })
45
+ })
@@ -5,11 +5,12 @@ import { Button } from '@open-mercato/ui/primitives/button'
5
5
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
6
6
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
7
7
  import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
8
+ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
8
9
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
- import { loadDashboardWidgetModule } from './widgetRegistry'
10
+ import { getDashboardWidgets, loadDashboardWidgetModule } from './widgetRegistry'
10
11
  import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
11
12
  import { cn } from '@open-mercato/shared/lib/utils'
12
- import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
13
+ import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
13
14
  import { useT } from '@open-mercato/shared/lib/i18n/context'
14
15
  import { InjectionSpot } from '../injection/InjectionSpot'
15
16
 
@@ -93,6 +94,7 @@ export function DashboardScreen() {
93
94
  const t = useT()
94
95
  const [loading, setLoading] = React.useState(true)
95
96
  const [error, setError] = React.useState<string | null>(null)
97
+ const [hasRegisteredWidgets, setHasRegisteredWidgets] = React.useState(true)
96
98
  const [saving, setSaving] = React.useState(false)
97
99
  const [layout, setLayout] = React.useState<LayoutItem[]>([])
98
100
  const [widgetCatalog, setWidgetCatalog] = React.useState<WidgetMeta[]>([])
@@ -119,9 +121,11 @@ export function DashboardScreen() {
119
121
  throw new Error(`Failed with status ${call.status}`)
120
122
  }
121
123
  const data = call.result
124
+ const registeredWidgetCount = getDashboardWidgets().length
122
125
  const normalizedLayout = sortLayout(data.layout?.items ?? [])
123
126
  setLayout(normalizedLayout)
124
127
  setWidgetCatalog(data.widgets ?? [])
128
+ setHasRegisteredWidgets(registeredWidgetCount > 0 || (data.widgets ?? []).length > 0)
125
129
  setAllowedWidgetIds(data.allowedWidgetIds ?? [])
126
130
  setCanConfigure(!!data.canConfigure)
127
131
  if (data.context) {
@@ -142,6 +146,17 @@ export function DashboardScreen() {
142
146
  }
143
147
  } catch (err) {
144
148
  console.error('Failed to load dashboard layout', err)
149
+ if (getDashboardWidgets().length === 0) {
150
+ setHasRegisteredWidgets(false)
151
+ setLayout([])
152
+ setWidgetCatalog([])
153
+ setAllowedWidgetIds([])
154
+ setCanConfigure(false)
155
+ setContext(null)
156
+ setEditing(false)
157
+ setSettingsId(null)
158
+ return
159
+ }
145
160
  setError(t('dashboard.loadError'))
146
161
  } finally {
147
162
  setLoading(false)
@@ -345,6 +360,21 @@ export function DashboardScreen() {
345
360
  )
346
361
  }
347
362
 
363
+ if (!hasRegisteredWidgets && layout.length === 0) {
364
+ return (
365
+ <Alert variant="info">
366
+ <Info className="h-4 w-4" aria-hidden />
367
+ <AlertTitle>{t('dashboard.empty.noWidgets.title', 'No dashboard widgets yet')}</AlertTitle>
368
+ <AlertDescription>
369
+ {t(
370
+ 'dashboard.empty.noWidgets.description',
371
+ 'After you add the first module that exposes dashboard widgets, they will appear here.',
372
+ )}
373
+ </AlertDescription>
374
+ </Alert>
375
+ )
376
+ }
377
+
348
378
  return (
349
379
  <div className="space-y-6">
350
380
  <div className="flex flex-wrap items-center justify-between gap-3">
@@ -464,7 +494,12 @@ export function DashboardScreen() {
464
494
 
465
495
  {layout.length === 0 && (
466
496
  <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
467
- {canConfigure ? t('dashboard.empty.configurable') : t('dashboard.empty.readonly')}
497
+ {!hasRegisteredWidgets
498
+ ? t(
499
+ 'dashboard.empty.noWidgets.description',
500
+ 'After you add the first module that exposes dashboard widgets, they will appear here.',
501
+ )
502
+ : canConfigure ? t('dashboard.empty.configurable') : t('dashboard.empty.readonly')}
468
503
  </div>
469
504
  )}
470
505
 
@@ -7,7 +7,7 @@ import { screen, waitFor } from '@testing-library/react'
7
7
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
8
  import { DashboardScreen } from '../DashboardScreen'
9
9
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
10
- import { loadDashboardWidgetModule } from '../widgetRegistry'
10
+ import { getDashboardWidgets, loadDashboardWidgetModule } from '../widgetRegistry'
11
11
 
12
12
  jest.setTimeout(20000)
13
13
 
@@ -16,6 +16,7 @@ jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
16
16
  }))
17
17
 
18
18
  jest.mock('../widgetRegistry', () => ({
19
+ getDashboardWidgets: jest.fn(),
19
20
  loadDashboardWidgetModule: jest.fn(),
20
21
  }))
21
22
 
@@ -31,6 +32,8 @@ const createMockResponse = (status: number): Response => ({ status } as Response
31
32
 
32
33
  const dict = {
33
34
  'dashboard.loadError': 'Failed to load dashboard',
35
+ 'dashboard.empty.noWidgets.title': 'No dashboard widgets yet',
36
+ 'dashboard.empty.noWidgets.description': 'Dashboard widgets will appear here after you add a module.',
34
37
  'dashboard.widgets.foo.title': 'Widget Foo',
35
38
  'dashboard.widgets.foo.description': 'Widget description',
36
39
  }
@@ -71,6 +74,7 @@ function MockWidget() {
71
74
  describe('DashboardScreen', () => {
72
75
  beforeEach(() => {
73
76
  jest.resetAllMocks()
77
+ ;(getDashboardWidgets as jest.Mock).mockReturnValue([{ key: 'foo.loader', loader: jest.fn() }])
74
78
  ;(loadDashboardWidgetModule as jest.Mock).mockResolvedValue({
75
79
  Widget: MockWidget,
76
80
  hydrateSettings: (value: unknown) => value,
@@ -111,4 +115,23 @@ describe('DashboardScreen', () => {
111
115
 
112
116
  errorSpy.mockRestore()
113
117
  })
118
+
119
+ it('shows an informational empty state when no dashboard widgets are registered', async () => {
120
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
121
+ ;(getDashboardWidgets as jest.Mock).mockReturnValue([])
122
+ ;(apiCall as jest.Mock).mockResolvedValue({
123
+ ok: false,
124
+ status: 500,
125
+ result: null,
126
+ response: createMockResponse(500),
127
+ })
128
+
129
+ renderWithProviders(<DashboardScreen />, { dict })
130
+
131
+ expect(await screen.findByText('No dashboard widgets yet')).toBeInTheDocument()
132
+ expect(screen.getByText('Dashboard widgets will appear here after you add a module.')).toBeInTheDocument()
133
+ expect(screen.queryByText('Failed to load dashboard')).not.toBeInTheDocument()
134
+
135
+ errorSpy.mockRestore()
136
+ })
114
137
  })
@@ -33,6 +33,12 @@ export const DataTableInjectionSpots = {
33
33
  header: (tableId: string): InjectionSpotId => `data-table:${tableId}:header`,
34
34
  footer: (tableId: string): InjectionSpotId => `data-table:${tableId}:footer`,
35
35
  toolbar: (tableId: string): InjectionSpotId => `data-table:${tableId}:toolbar`,
36
+ // Slot rendered immediately after the search input on the same row as the
37
+ // FilterBar — intended for compact, icon-sized triggers (AI assistants,
38
+ // saved view shortcuts, etc.). Hosts pass the resolved spot ID through to
39
+ // FilterBar's `searchTrailing` prop. Stays empty when the table has no
40
+ // search input.
41
+ searchTrailing: (tableId: string): InjectionSpotId => `data-table:${tableId}:search-trailing`,
36
42
  emptyState: (tableId: string): InjectionSpotId => `data-table:${tableId}:empty-state`,
37
43
  columns: (tableId: string): InjectionSpotId => `data-table:${tableId}:columns`,
38
44
  rowActions: (tableId: string): InjectionSpotId => `data-table:${tableId}:row-actions`,