@open-mercato/ui 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (214) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -2
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +2 -54
  5. package/dist/ai/AiChat.js +26 -7
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/LoopDisabledBanner.js +1 -1
  8. package/dist/ai/LoopDisabledBanner.js.map +2 -2
  9. package/dist/ai/parts/ConfirmationCard.js +4 -4
  10. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  11. package/dist/ai/parts/FieldDiffCard.js +2 -2
  12. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  13. package/dist/ai/parts/MutationResultCard.js +3 -3
  14. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  15. package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
  16. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  17. package/dist/backend/AppShell.js +43 -4
  18. package/dist/backend/AppShell.js.map +2 -2
  19. package/dist/backend/AuthSessionGuard.js +14 -0
  20. package/dist/backend/AuthSessionGuard.js.map +2 -2
  21. package/dist/backend/BackendChromeProvider.js +6 -1
  22. package/dist/backend/BackendChromeProvider.js.map +2 -2
  23. package/dist/backend/CrudForm.js +6 -4
  24. package/dist/backend/CrudForm.js.map +2 -2
  25. package/dist/backend/DataTable.js +20 -18
  26. package/dist/backend/DataTable.js.map +2 -2
  27. package/dist/backend/JsonBuilder.js +3 -3
  28. package/dist/backend/JsonBuilder.js.map +2 -2
  29. package/dist/backend/NextStepCallout.js +4 -4
  30. package/dist/backend/NextStepCallout.js.map +1 -1
  31. package/dist/backend/PerspectiveSidebar.js +1 -1
  32. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  33. package/dist/backend/ProfileDropdown.js +2 -1
  34. package/dist/backend/ProfileDropdown.js.map +2 -2
  35. package/dist/backend/RowActions.js +2 -2
  36. package/dist/backend/RowActions.js.map +2 -2
  37. package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
  38. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  39. package/dist/backend/crud/useGroupOrder.js +14 -21
  40. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  41. package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
  42. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
  43. package/dist/backend/dashboard/DashboardScreen.js +3 -3
  44. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  45. package/dist/backend/detail/AddressTiles.js +4 -5
  46. package/dist/backend/detail/AddressTiles.js.map +2 -2
  47. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  48. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  49. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  50. package/dist/backend/detail/AttachmentsSection.js +1 -1
  51. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  52. package/dist/backend/detail/NotesSection.js +1 -1
  53. package/dist/backend/detail/NotesSection.js.map +2 -2
  54. package/dist/backend/detail/TagsSection.js +1 -1
  55. package/dist/backend/detail/TagsSection.js.map +2 -2
  56. package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
  57. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  58. package/dist/backend/forms/FormHeader.js +1 -2
  59. package/dist/backend/forms/FormHeader.js.map +2 -2
  60. package/dist/backend/icons/lucideRegistry.generated.js +1 -25
  61. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  62. package/dist/backend/icons/lucideRegistry.js +3 -3
  63. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  64. package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
  65. package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
  66. package/dist/backend/injection/spotIds.js +23 -18
  67. package/dist/backend/injection/spotIds.js.map +2 -2
  68. package/dist/backend/inputs/ComboboxInput.js.map +1 -1
  69. package/dist/backend/messages/EmailThreadsPanel.js +1 -1
  70. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  71. package/dist/backend/perspectiveState.js +34 -0
  72. package/dist/backend/perspectiveState.js.map +7 -0
  73. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  74. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  75. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  76. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  77. package/dist/backend/schedule/ScheduleToolbar.js +87 -70
  78. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  79. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
  80. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  81. package/dist/backend/sidebar/customization-helpers.js +30 -0
  82. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  83. package/dist/backend/utils/apiCall.js +21 -1
  84. package/dist/backend/utils/apiCall.js.map +2 -2
  85. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  86. package/dist/backend/utils/customFieldForms.js +10 -9
  87. package/dist/backend/utils/customFieldForms.js.map +2 -2
  88. package/dist/backend/utils/fetchAllPages.js +57 -0
  89. package/dist/backend/utils/fetchAllPages.js.map +7 -0
  90. package/dist/backend/utils/nav.js +5 -2
  91. package/dist/backend/utils/nav.js.map +2 -2
  92. package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
  93. package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
  94. package/dist/frontend/LanguageSwitcher.js +2 -1
  95. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  96. package/dist/portal/PortalContext.js +1 -1
  97. package/dist/portal/PortalContext.js.map +2 -2
  98. package/dist/portal/hooks/portalBridgeStatus.js +25 -0
  99. package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
  100. package/dist/portal/hooks/usePortalEventBridge.js +6 -0
  101. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  102. package/dist/portal/hooks/usePortalNotifications.js +42 -4
  103. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  104. package/dist/portal/utils/nav.js +2 -3
  105. package/dist/portal/utils/nav.js.map +2 -2
  106. package/dist/primitives/ErrorNotice.js +1 -1
  107. package/dist/primitives/ErrorNotice.js.map +2 -2
  108. package/dist/primitives/Notice.js +1 -1
  109. package/dist/primitives/Notice.js.map +1 -1
  110. package/dist/primitives/button.js +9 -4
  111. package/dist/primitives/button.js.map +2 -2
  112. package/dist/theme/ThemeProvider.js +1 -1
  113. package/dist/theme/ThemeProvider.js.map +2 -2
  114. package/dist/theme/index.js +3 -0
  115. package/dist/theme/index.js.map +2 -2
  116. package/dist/theme/theme-init-script.js +17 -0
  117. package/dist/theme/theme-init-script.js.map +7 -0
  118. package/figma/alert.figma.tsx +35 -0
  119. package/figma/badge.figma.tsx +33 -0
  120. package/figma/button.figma.tsx +33 -0
  121. package/figma/checkbox.figma.tsx +28 -0
  122. package/figma/drawer.figma.tsx +40 -0
  123. package/figma/input-variants.figma.tsx +58 -0
  124. package/figma/radio.figma.tsx +16 -0
  125. package/figma/select.figma.tsx +24 -0
  126. package/figma/status-badge.figma.tsx +24 -0
  127. package/figma/switch.figma.tsx +28 -0
  128. package/figma/tabs.figma.tsx +22 -0
  129. package/figma/tag.figma.tsx +27 -0
  130. package/figma.config.json +6 -0
  131. package/jest.config.cjs +2 -1
  132. package/package.json +21 -18
  133. package/scripts/lucideRegistrySource.cjs +133 -0
  134. package/src/ai/AiChat.tsx +36 -7
  135. package/src/ai/LoopDisabledBanner.tsx +1 -1
  136. package/src/ai/__tests__/AiChat.test.tsx +31 -0
  137. package/src/ai/parts/ConfirmationCard.tsx +4 -4
  138. package/src/ai/parts/FieldDiffCard.tsx +2 -2
  139. package/src/ai/parts/MutationResultCard.tsx +3 -3
  140. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
  141. package/src/backend/AppShell.tsx +55 -4
  142. package/src/backend/AuthSessionGuard.tsx +29 -0
  143. package/src/backend/BackendChromeProvider.tsx +19 -1
  144. package/src/backend/CrudForm.tsx +18 -4
  145. package/src/backend/DataTable.tsx +36 -19
  146. package/src/backend/JsonBuilder.tsx +3 -3
  147. package/src/backend/NextStepCallout.tsx +4 -4
  148. package/src/backend/PerspectiveSidebar.tsx +1 -1
  149. package/src/backend/ProfileDropdown.tsx +1 -0
  150. package/src/backend/RowActions.tsx +2 -2
  151. package/src/backend/__tests__/AppShell.test.tsx +65 -7
  152. package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
  153. package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
  154. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
  155. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
  156. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
  157. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
  158. package/src/backend/__tests__/nav-utils.test.ts +139 -0
  159. package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
  160. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
  161. package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
  162. package/src/backend/crud/useGroupOrder.ts +30 -25
  163. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
  164. package/src/backend/dashboard/DashboardScreen.tsx +3 -3
  165. package/src/backend/detail/AddressTiles.tsx +3 -6
  166. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  167. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  168. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  169. package/src/backend/detail/NotesSection.tsx +1 -1
  170. package/src/backend/detail/TagsSection.tsx +1 -1
  171. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
  172. package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
  173. package/src/backend/forms/FormHeader.tsx +1 -2
  174. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
  175. package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
  176. package/src/backend/icons/lucideRegistry.ts +2 -2
  177. package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
  178. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
  179. package/src/backend/injection/spotIds.ts +23 -18
  180. package/src/backend/inputs/ComboboxInput.tsx +1 -1
  181. package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
  182. package/src/backend/perspectiveState.ts +44 -0
  183. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  184. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  185. package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
  186. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
  187. package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
  188. package/src/backend/sidebar/customization-helpers.ts +47 -0
  189. package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
  190. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
  191. package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
  192. package/src/backend/utils/apiCall.ts +29 -1
  193. package/src/backend/utils/customFieldDefs.ts +1 -0
  194. package/src/backend/utils/customFieldForms.ts +15 -11
  195. package/src/backend/utils/fetchAllPages.ts +93 -0
  196. package/src/backend/utils/nav.ts +22 -2
  197. package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
  198. package/src/frontend/LanguageSwitcher.tsx +1 -0
  199. package/src/portal/PortalContext.tsx +1 -1
  200. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
  201. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
  202. package/src/portal/hooks/portalBridgeStatus.ts +29 -0
  203. package/src/portal/hooks/usePortalEventBridge.ts +6 -0
  204. package/src/portal/hooks/usePortalNotifications.ts +55 -12
  205. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  206. package/src/portal/utils/nav.ts +3 -4
  207. package/src/primitives/ErrorNotice.tsx +1 -1
  208. package/src/primitives/Notice.tsx +5 -5
  209. package/src/primitives/__tests__/button.test.tsx +70 -0
  210. package/src/primitives/button.tsx +10 -4
  211. package/src/theme/ThemeProvider.tsx +1 -2
  212. package/src/theme/__tests__/theme-init-script.test.ts +70 -0
  213. package/src/theme/index.ts +1 -0
  214. package/src/theme/theme-init-script.ts +23 -0
@@ -0,0 +1,133 @@
1
+ // Pure source emitter for src/backend/icons/lucideRegistry.generated.tsx.
2
+ // Kept side-effect free, and CommonJS so both build.mjs and the Jest suite can load it.
3
+
4
+ const {
5
+ IndentationText,
6
+ Project,
7
+ QuoteKind,
8
+ ScriptKind,
9
+ StructureKind,
10
+ VariableDeclarationKind,
11
+ } = require('ts-morph')
12
+
13
+ const GENERATED_FILE_NAME = 'lucideRegistry.generated.tsx'
14
+ const LUCIDE_MODULE_SPECIFIER = 'lucide-react'
15
+ const BANNER = `/* eslint-disable */
16
+ // AUTO-GENERATED by @open-mercato/ui build. Do not edit by hand.
17
+ // Generated from discovered icon string usages across the repo.
18
+
19
+ `
20
+
21
+ let sharedProject = null
22
+
23
+ function getProject() {
24
+ if (!sharedProject) {
25
+ sharedProject = new Project({
26
+ useInMemoryFileSystem: true,
27
+ manipulationSettings: {
28
+ indentationText: IndentationText.TwoSpaces,
29
+ quoteKind: QuoteKind.Single,
30
+ useTrailingCommas: true,
31
+ },
32
+ })
33
+ }
34
+
35
+ return sharedProject
36
+ }
37
+
38
+ function sortResolvedIcons(resolvedIcons) {
39
+ return [...resolvedIcons].sort((a, b) => a.kebab.localeCompare(b.kebab))
40
+ }
41
+
42
+ function collectUniqueExports(resolvedIcons) {
43
+ return [...new Set(resolvedIcons.map((icon) => icon.exportName))].sort((a, b) => a.localeCompare(b))
44
+ }
45
+
46
+ function writeNamedImports(exportNames) {
47
+ return (writer) => {
48
+ writer.newLine()
49
+ for (const name of exportNames) {
50
+ writer.write(`${name},`)
51
+ writer.newLine()
52
+ }
53
+ }
54
+ }
55
+
56
+ function writeRegistryLiteral(entries) {
57
+ return (writer) => {
58
+ if (entries.length === 0) {
59
+ writer.write('{}')
60
+ return
61
+ }
62
+
63
+ writer.write('{')
64
+ writer.newLine()
65
+ for (const icon of entries) {
66
+ writer.write(`'${icon.kebab}': ${icon.exportName},`)
67
+ writer.newLine()
68
+ }
69
+ writer.write('}')
70
+ }
71
+ }
72
+
73
+ function assertNoSyntacticDiagnostics(project, sourceFile) {
74
+ const diagnostics = project.getProgram().getSyntacticDiagnostics(sourceFile)
75
+ if (diagnostics.length === 0) return
76
+
77
+ const details = diagnostics
78
+ .map((diagnostic) => ` line ${diagnostic.getLineNumber() ?? 0}: ${diagnostic.getMessageText()}`)
79
+ .join('\n')
80
+ throw new Error(`[internal] Generated ${GENERATED_FILE_NAME} is not syntactically valid:\n${details}`)
81
+ }
82
+
83
+ function normalizeSourceText(text) {
84
+ const normalized = text.replace(/\r\n/g, '\n')
85
+ return normalized.endsWith('\n') ? normalized : `${normalized}\n`
86
+ }
87
+
88
+ function buildLucideRegistrySource(resolvedIcons) {
89
+ const entries = sortResolvedIcons(resolvedIcons)
90
+ const uniqueExports = collectUniqueExports(entries)
91
+
92
+ const project = getProject()
93
+ const sourceFile = project.createSourceFile(GENERATED_FILE_NAME, '', {
94
+ overwrite: true,
95
+ scriptKind: ScriptKind.TSX,
96
+ })
97
+
98
+ sourceFile.addImportDeclaration({
99
+ kind: StructureKind.ImportDeclaration,
100
+ isTypeOnly: true,
101
+ namedImports: [{ name: 'LucideIcon' }],
102
+ moduleSpecifier: LUCIDE_MODULE_SPECIFIER,
103
+ })
104
+
105
+ if (uniqueExports.length > 0) {
106
+ sourceFile.addImportDeclaration({
107
+ kind: StructureKind.ImportDeclaration,
108
+ namedImports: writeNamedImports(uniqueExports),
109
+ moduleSpecifier: LUCIDE_MODULE_SPECIFIER,
110
+ })
111
+ }
112
+
113
+ sourceFile.addVariableStatement({
114
+ kind: StructureKind.VariableStatement,
115
+ isExported: true,
116
+ declarationKind: VariableDeclarationKind.Const,
117
+ declarations: [
118
+ {
119
+ name: 'LUCIDE_ICON_REGISTRY',
120
+ type: 'Record<string, LucideIcon>',
121
+ initializer: writeRegistryLiteral(entries),
122
+ },
123
+ ],
124
+ })
125
+
126
+ sourceFile.formatText({ indentSize: 2, convertTabsToSpaces: true })
127
+ sourceFile.insertText(0, BANNER)
128
+ assertNoSyntacticDiagnostics(project, sourceFile)
129
+
130
+ return normalizeSourceText(sourceFile.getFullText())
131
+ }
132
+
133
+ module.exports = { buildLucideRegistrySource }
package/src/ai/AiChat.tsx CHANGED
@@ -292,10 +292,10 @@ interface ServerEmittedUiPartRef {
292
292
  pendingActionId?: string
293
293
  }
294
294
 
295
- function mapErrorCodeToVariant(
295
+ function mapErrorCodeToStatus(
296
296
  code: string | undefined,
297
- ): 'destructive' | 'warning' {
298
- if (!code) return 'destructive'
297
+ ): 'error' | 'warning' {
298
+ if (!code) return 'error'
299
299
  // Policy denies that describe a filtered tool or attachment surface a
300
300
  // warning alert; caller can still continue. Hard denials (agent_unknown,
301
301
  // agent_features_denied, unauthenticated, execution_mode_not_supported,
@@ -304,8 +304,37 @@ function mapErrorCodeToVariant(
304
304
  'tool_not_whitelisted',
305
305
  'tool_features_denied',
306
306
  'attachment_type_not_accepted',
307
+ // Content-safety rejections are soft: the user can rephrase and retry.
308
+ 'moderation_blocked',
309
+ 'moderation_unavailable',
307
310
  ])
308
- return warningCodes.has(code) ? 'warning' : 'destructive'
311
+ return warningCodes.has(code) ? 'warning' : 'error'
312
+ }
313
+
314
+ /**
315
+ * Maps known error codes to a translated, user-safe message. For content-safety
316
+ * codes the raw server message is intentionally generic (no category oracle);
317
+ * the UI shows the friendly localized copy instead of the internal text.
318
+ */
319
+ function resolveChatErrorMessage(
320
+ code: string | undefined,
321
+ fallback: string,
322
+ translate: (key: string, fallbackText?: string) => string,
323
+ ): string {
324
+ switch (code) {
325
+ case 'moderation_blocked':
326
+ return translate(
327
+ 'ai_assistant.errors.moderationBlocked',
328
+ 'Your message was blocked by the content safety filter. Please rephrase and try again.',
329
+ )
330
+ case 'moderation_unavailable':
331
+ return translate(
332
+ 'ai_assistant.errors.moderationUnavailable',
333
+ 'The content safety check is temporarily unavailable. Please try again in a moment.',
334
+ )
335
+ default:
336
+ return fallback
337
+ }
309
338
  }
310
339
 
311
340
  const MARKDOWN_TYPOGRAPHY_CLASS = cn(
@@ -1541,7 +1570,7 @@ export function AiChat({
1541
1570
  const resolvedPlaceholder =
1542
1571
  placeholder ?? t('ai_assistant.chat.composerPlaceholder', 'Message the AI agent...')
1543
1572
 
1544
- const errorVariant = mapErrorCodeToVariant(chat.error?.code)
1573
+ const errorStatus = mapErrorCodeToStatus(chat.error?.code)
1545
1574
 
1546
1575
  return (
1547
1576
  <section
@@ -1622,7 +1651,7 @@ export function AiChat({
1622
1651
  </div>
1623
1652
 
1624
1653
  {chat.error ? (
1625
- <Alert variant={errorVariant} data-ai-chat-error={chat.error.code ?? 'unknown'}>
1654
+ <Alert status={errorStatus} data-ai-chat-error={chat.error.code ?? 'unknown'}>
1626
1655
  <AlertTitle>
1627
1656
  {t('ai_assistant.chat.errorTitle', 'Agent dispatch failed')}
1628
1657
  </AlertTitle>
@@ -1630,7 +1659,7 @@ export function AiChat({
1630
1659
  {chat.error.code ? (
1631
1660
  <span className="mr-2 font-mono text-xs">{chat.error.code}</span>
1632
1661
  ) : null}
1633
- {chat.error.message}
1662
+ {resolveChatErrorMessage(chat.error.code, chat.error.message, t)}
1634
1663
  </AlertDescription>
1635
1664
  </Alert>
1636
1665
  ) : null}
@@ -19,7 +19,7 @@ export function LoopDisabledBanner({ agentId }: { agentId?: string }) {
19
19
  const t = useT()
20
20
  return (
21
21
  <Alert
22
- variant="warning"
22
+ status="warning"
23
23
  icon={<ShieldOff aria-hidden="true" />}
24
24
  data-ai-loop-disabled-banner
25
25
  data-ai-loop-disabled-agent-id={agentId}
@@ -93,6 +93,8 @@ const dict = {
93
93
  'ai_assistant.chat.emptyTranscript':
94
94
  'No messages yet. Ask the agent anything to get started.',
95
95
  'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
96
+ 'ai_assistant.errors.moderationBlocked':
97
+ 'Your message was blocked by the content safety filter. Please rephrase and try again.',
96
98
  'ai_assistant.chat.agentTasksTitle': 'Tool calls',
97
99
  'ai_assistant.chat.regionLabel': 'AI chat',
98
100
  'ai_assistant.chat.send': 'Send message',
@@ -577,6 +579,35 @@ describe('<AiChat>', () => {
577
579
  expect(screen.getByText('agent_unknown')).toBeInTheDocument()
578
580
  })
579
581
 
582
+ it('renders the translated message (not the raw server text) for a moderation_blocked envelope', async () => {
583
+ const fetchMock = apiFetch as unknown as jest.Mock
584
+ fetchMock.mockResolvedValueOnce(
585
+ createErrorResponse(400, {
586
+ error: 'Input rejected by the content safety filter.',
587
+ code: 'moderation_blocked',
588
+ }),
589
+ )
590
+
591
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
592
+
593
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
594
+ fireEvent.change(textarea, { target: { value: 'flagged input' } })
595
+ await act(async () => {
596
+ fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
597
+ })
598
+
599
+ await waitFor(() => {
600
+ expect(screen.getByText('moderation_blocked')).toBeInTheDocument()
601
+ })
602
+ // The friendly localized copy is shown; the internal server text is not.
603
+ expect(
604
+ screen.getByText(/blocked by the content safety filter/i),
605
+ ).toBeInTheDocument()
606
+ expect(screen.queryByText('Input rejected by the content safety filter.')).toBeNull()
607
+ // Content-safety rejections render as a soft warning, not destructive.
608
+ expect(document.querySelector('[data-ai-chat-error="moderation_blocked"]')).not.toBeNull()
609
+ })
610
+
580
611
  it('Escape aborts an in-flight streaming response', async () => {
581
612
  const fetchMock = apiFetch as unknown as jest.Mock
582
613
  // Build a stream we can keep open until the component aborts it.
@@ -210,7 +210,7 @@ function ConfirmationErrorAlert({
210
210
  : []
211
211
  return (
212
212
  <Alert
213
- variant="warning"
213
+ status="warning"
214
214
  className="mt-3"
215
215
  data-ai-confirmation-error="stale_version"
216
216
  >
@@ -247,7 +247,7 @@ function ConfirmationErrorAlert({
247
247
  if (code === 'schema_drift') {
248
248
  return (
249
249
  <Alert
250
- variant="warning"
250
+ status="warning"
251
251
  className="mt-3"
252
252
  data-ai-confirmation-error="schema_drift"
253
253
  >
@@ -270,7 +270,7 @@ function ConfirmationErrorAlert({
270
270
  if (code === 'invalid_status') {
271
271
  return (
272
272
  <Alert
273
- variant="warning"
273
+ status="warning"
274
274
  className="mt-3"
275
275
  data-ai-confirmation-error="invalid_status"
276
276
  >
@@ -291,7 +291,7 @@ function ConfirmationErrorAlert({
291
291
  }
292
292
 
293
293
  return (
294
- <Alert variant="destructive" className="mt-3" data-ai-confirmation-error={code}>
294
+ <Alert status="error" className="mt-3" data-ai-confirmation-error={code}>
295
295
  <AlertTitle>
296
296
  {t('ai_assistant.chat.mutation_cards.confirmation.errorTitle', 'Confirm failed')}
297
297
  </AlertTitle>
@@ -128,7 +128,7 @@ export function FieldDiffCard({ fieldDiff, records }: FieldDiffCardProps) {
128
128
  afterHeader={afterHeader}
129
129
  />
130
130
  ) : (
131
- <Alert variant="info">
131
+ <Alert status="information">
132
132
  <AlertDescription>
133
133
  {t(
134
134
  'ai_assistant.chat.mutation_cards.diff.empty',
@@ -145,7 +145,7 @@ export function FieldDiffCard({ fieldDiff, records }: FieldDiffCardProps) {
145
145
 
146
146
  if (flat.length === 0) {
147
147
  return (
148
- <Alert variant="info" data-ai-field-diff-mode="empty">
148
+ <Alert status="information" data-ai-field-diff-mode="empty">
149
149
  <AlertDescription>
150
150
  {t(
151
151
  'ai_assistant.chat.mutation_cards.diff.empty',
@@ -79,7 +79,7 @@ export function MutationResultCard(props: MutationResultCardProps) {
79
79
 
80
80
  if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {
81
81
  return (
82
- <Alert variant="warning" data-ai-mutation-result="partial">
82
+ <Alert status="warning" data-ai-mutation-result="partial">
83
83
  <AlertTitle>
84
84
  {t(
85
85
  'ai_assistant.chat.mutation_cards.result.partialTitle',
@@ -116,7 +116,7 @@ export function MutationResultCard(props: MutationResultCardProps) {
116
116
  const recordId = result?.recordId ?? action.targetRecordId ?? null
117
117
  const href = payload.recordHref ?? null
118
118
  return (
119
- <Alert variant="success" data-ai-mutation-result="success">
119
+ <Alert status="success" data-ai-mutation-result="success">
120
120
  <AlertTitle>
121
121
  {t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')}
122
122
  </AlertTitle>
@@ -281,7 +281,7 @@ export function MutationResultCard(props: MutationResultCardProps) {
281
281
  ? errorDetails!.issues!.filter((entry) => entry && (entry.message || entry.path))
282
282
  : []
283
283
  return (
284
- <Alert variant="destructive" data-ai-mutation-result="failure">
284
+ <Alert status="error" data-ai-mutation-result="failure">
285
285
  <AlertTitle>
286
286
  {t(
287
287
  'ai_assistant.chat.mutation_cards.result.failureTitle',
@@ -11,7 +11,7 @@ import type { AiUiPartProps } from '../ui-part-registry'
11
11
  * Consumers that forget to register the real mutation-approval cards still
12
12
  * see a humane "Phase 3 pending" state instead of the neutral debug chip the
13
13
  * `<AiChat>` fallback uses for genuinely unknown component ids. Uses the
14
- * shared DS `Alert` primitive with `variant="info"` — no hardcoded colors.
14
+ * shared DS `Alert` primitive with `status="information"` — no hardcoded colors.
15
15
  *
16
16
  * When Step 5.10 lands the real cards, app bootstrappers will call
17
17
  * `registerAiUiPart('mutation-preview-card', MutationPreviewCard)` (etc.)
@@ -23,7 +23,7 @@ export function PendingPhase3Placeholder({ componentId }: AiUiPartProps) {
23
23
  const t = useT()
24
24
  return (
25
25
  <Alert
26
- variant="info"
26
+ status="information"
27
27
  data-ai-ui-part-pending-phase3={componentId}
28
28
  >
29
29
  <AlertTitle>
@@ -87,6 +87,7 @@ function isBooleanRecord(value: unknown): value is Record<string, boolean> {
87
87
  export type ShellLogo = {
88
88
  src: string
89
89
  alt?: string
90
+ preserveAspectRatio?: boolean
90
91
  }
91
92
 
92
93
  export type AppShellProps = {
@@ -186,6 +187,56 @@ function shouldBypassLogoOptimization(src?: string | null): boolean {
186
187
  return /^https?:\/\//.test(value) || /^\/api\/attachments\/(?:image|file)\//.test(value)
187
188
  }
188
189
 
190
+ function ShellBrandLogo({
191
+ logo,
192
+ brandName,
193
+ unoptimized,
194
+ compact = false,
195
+ mobile = false,
196
+ }: {
197
+ logo?: ShellLogo
198
+ brandName: string
199
+ unoptimized?: boolean
200
+ compact?: boolean
201
+ mobile?: boolean
202
+ }) {
203
+ const src = logo?.src ?? '/open-mercato.svg'
204
+ const alt = logo?.alt ?? brandName
205
+ const isCustomLogo = Boolean(logo?.src)
206
+ const preserveAspectRatio = Boolean(logo?.preserveAspectRatio)
207
+ if (!isCustomLogo || !preserveAspectRatio) {
208
+ return (
209
+ <Image
210
+ src={src}
211
+ alt={alt}
212
+ width={mobile ? 28 : 40}
213
+ height={mobile ? 28 : 40}
214
+ className={`${mobile ? 'rounded' : 'rounded-full'} shrink-0 object-cover`}
215
+ unoptimized={unoptimized ? true : undefined}
216
+ />
217
+ )
218
+ }
219
+
220
+ const width = compact ? 40 : mobile ? 96 : 120
221
+ const height = mobile ? 28 : 40
222
+ const className = compact
223
+ ? 'h-10 max-w-10 w-auto shrink-0 object-contain'
224
+ : mobile
225
+ ? 'h-7 max-w-24 w-auto shrink-0 object-contain'
226
+ : 'h-10 max-w-[120px] w-auto shrink-0 object-contain'
227
+
228
+ return (
229
+ <Image
230
+ src={src}
231
+ alt={alt}
232
+ width={width}
233
+ height={height}
234
+ className={className}
235
+ unoptimized={unoptimized ? true : undefined}
236
+ />
237
+ )
238
+ }
239
+
189
240
  function mergeSidebarItemsWithInjected(
190
241
  items: SidebarItem[],
191
242
  injectedItems: InjectionMenuItem[],
@@ -760,7 +811,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
760
811
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
761
812
  aria-label={t('appShell.goToDashboard')}
762
813
  >
763
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
814
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
764
815
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
765
816
  </Link>
766
817
  </div>
@@ -893,7 +944,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
893
944
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
894
945
  aria-label={t('appShell.goToDashboard')}
895
946
  >
896
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
947
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
897
948
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
898
949
  </Link>
899
950
  </div>
@@ -959,7 +1010,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
959
1010
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
960
1011
  aria-label={t('appShell.goToDashboard')}
961
1012
  >
962
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1013
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
963
1014
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
964
1015
  </Link>
965
1016
  </div>
@@ -1458,7 +1509,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1458
1509
  <aside className="absolute left-0 top-0 flex h-full w-[280px] max-w-[85vw] flex-col bg-background border-r shadow-lg overflow-hidden">
1459
1510
  <div className="shrink-0 flex items-center justify-between gap-2 border-b px-4 py-3">
1460
1511
  <Link href="/backend" className="flex items-center gap-2 min-w-0 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
1461
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={28} height={28} className="rounded shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1512
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} mobile unoptimized={resolvedLogoBypassesOptimization} />
1462
1513
  <span className="truncate">{resolvedBrandName}</span>
1463
1514
  </Link>
1464
1515
  <IconButton variant="ghost" size="sm" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>
@@ -1,9 +1,37 @@
1
1
  'use client'
2
2
  import * as React from 'react'
3
3
  import { apiCall } from './utils/apiCall'
4
+ import { clearAllPerspectiveState } from './perspectiveState'
4
5
 
5
6
  export const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'
6
7
  export const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'
8
+ export const AUTH_IDENTITY_USER_STORAGE_KEY = 'om:auth:identity:user'
9
+
10
+ /**
11
+ * Purge browser-local DataTable perspective state whenever the backend renders for a
12
+ * different user than the one it last rendered for (#4185).
13
+ *
14
+ * The login form also purges on submit, but that only fires when its client handler
15
+ * runs: a form submitted before hydration, an SSO/magic-link return, or any other
16
+ * entry point into the backend bypasses it entirely and the previous account's unsaved
17
+ * column widths carry over. Anchoring the purge to the observed server identity covers
18
+ * every route into the backend instead of one form.
19
+ *
20
+ * Missing identity metadata is treated as a legacy browser state and purged once:
21
+ * snapshots may predate this marker and belong to a different account. After the
22
+ * marker is recorded, an account's own unsaved widths survive a plain reload.
23
+ */
24
+ function purgePerspectiveStateOnIdentityChange(serverUserId: string | null): void {
25
+ if (typeof window === 'undefined' || !serverUserId) return
26
+ try {
27
+ const lastSeenUserId = window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)
28
+ if (lastSeenUserId === serverUserId) return
29
+ clearAllPerspectiveState()
30
+ window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, serverUserId)
31
+ } catch {
32
+ // private mode / quota errors are non-fatal
33
+ }
34
+ }
7
35
 
8
36
  type FeatureCheckResponse = {
9
37
  ok: boolean
@@ -24,6 +52,7 @@ export const __reload = {
24
52
  export function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {
25
53
  React.useEffect(() => {
26
54
  if (typeof window === 'undefined') return
55
+ purgePerspectiveStateOnIdentityChange(serverUserId)
27
56
  let cancelled = false
28
57
  let reloadScheduled = false
29
58
 
@@ -3,7 +3,10 @@
3
3
  import * as React from 'react'
4
4
  import { apiCall } from './utils/apiCall'
5
5
  import { subscribeOrganizationScopeChanged, getCurrentOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/organizationEvents'
6
- import type { BackendChromePayload } from '@open-mercato/shared/modules/navigation/backendChrome'
6
+ import type {
7
+ BackendChromeCurrentOrganization,
8
+ BackendChromePayload,
9
+ } from '@open-mercato/shared/modules/navigation/backendChrome'
7
10
 
8
11
  type BackendChromeContextValue = {
9
12
  payload: BackendChromePayload | null
@@ -99,3 +102,18 @@ export function useBackendChrome(): BackendChromeContextValue {
99
102
  refresh: async () => {},
100
103
  }
101
104
  }
105
+
106
+ /**
107
+ * The organization the backend chrome is currently scoped to, for UI that needs to label
108
+ * "you are viewing: <name>".
109
+ *
110
+ * Reads the payload this provider already holds, so it costs no additional request. Returns `null`
111
+ * under an all-organizations selection, outside a provider, or before the payload has arrived — treat
112
+ * it as "unknown", not as "no organization".
113
+ *
114
+ * Prefer this over `payload.brand`, which only populates when the organization has a logo configured.
115
+ */
116
+ export function useCurrentOrganization(): BackendChromeCurrentOrganization | null {
117
+ const { payload } = useBackendChrome()
118
+ return payload?.currentOrganization ?? null
119
+ }
@@ -107,6 +107,7 @@ import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/share
107
107
  import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
108
108
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
109
109
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
110
+ import { crudFormExtensionSpotId, extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
110
111
  import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
111
112
  import MarkdownField from './inputs/MarkdownField'
112
113
 
@@ -816,7 +817,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
816
817
  if (injectionSpotId) return injectionSpotId
817
818
  if (resolvedEntityIds.length) {
818
819
  const normalized = resolvedEntityIds[0].replace(/[:]+/g, '.')
819
- return `crud-form:${normalized}`
820
+ return crudFormExtensionSpotId(normalized)
820
821
  }
821
822
  return undefined
822
823
  }, [injectionSpotId, resolvedEntityIds])
@@ -825,7 +826,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
825
826
  if (resolvedEntityIds.length) return ComponentReplacementHandles.crudForm(resolvedEntityIds[0].replace(/[:]+/g, '.'))
826
827
  return ComponentReplacementHandles.crudForm('unknown')
827
828
  }, [replacementHandle, resolvedEntityIds])
828
- const headerInjectionSpotId = resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : undefined
829
+ const headerInjectionSpotId = resolvedInjectionSpotId
830
+ ? extensionSpotChildId(resolvedInjectionSpotId, 'header')
831
+ : undefined
829
832
 
830
833
  const recordId = React.useMemo(() => {
831
834
  const raw = values.id
@@ -1083,7 +1086,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1083
1086
  triggerOnLoad: true,
1084
1087
  })
1085
1088
  const { widgets: injectedFieldWidgets } = useInjectionDataWidgets(
1086
- resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:fields` : '__disabled__:fields'
1089
+ resolvedInjectionSpotId
1090
+ ? extensionSpotChildId(resolvedInjectionSpotId, 'fields')
1091
+ : '__disabled__:fields'
1087
1092
  )
1088
1093
 
1089
1094
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
@@ -2402,10 +2407,19 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2402
2407
 
2403
2408
  const handleFieldsetSelectionChange = React.useCallback(
2404
2409
  (entityId: string, nextCode: string | null) => {
2410
+ // The fieldset selector never renders an empty option, so a user can never
2411
+ // pick "no fieldset". An empty/null value here only comes from Radix's
2412
+ // hidden native <select>, which fires a spurious onChange when the
2413
+ // controlled value is changed programmatically after mount (e.g. when the
2414
+ // persisted customFieldsetCode arrives via async initialValues and the
2415
+ // binding hydrates the selection). Honoring that reset would wipe both the
2416
+ // selection and the bound value, collapsing the form back to the default
2417
+ // fieldset (#2646). Ignore it.
2418
+ if (!nextCode) return
2405
2419
  setCfFieldsetSelections((prev) => ({ ...prev, [entityId]: nextCode }))
2406
2420
  const bindingKey = customFieldsetBindings?.[entityId]?.valueKey
2407
2421
  if (bindingKey) {
2408
- setValue(bindingKey, nextCode ?? undefined)
2422
+ setValue(bindingKey, nextCode)
2409
2423
  }
2410
2424
  },
2411
2425
  [customFieldsetBindings, setValue],