@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,111 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { parseAiContentSegments } from '../AiMessageContent'
6
+
7
+ describe('parseAiContentSegments — fenced cards', () => {
8
+ it('parses a properly fenced product card', () => {
9
+ const content = [
10
+ 'Here is the product:',
11
+ '```open-mercato:product',
12
+ '{ "id": "abc", "name": "Wireless Headphones", "sku": "WH-001" }',
13
+ '```',
14
+ ].join('\n')
15
+ const segments = parseAiContentSegments(content)
16
+ expect(segments.some((s) => s.kind === 'record-card')).toBe(true)
17
+ const card = segments.find((s) => s.kind === 'record-card')
18
+ expect(card?.kind).toBe('record-card')
19
+ if (card?.kind === 'record-card' && card.payload.kind === 'product') {
20
+ expect(card.payload.name).toBe('Wireless Headphones')
21
+ }
22
+ })
23
+ })
24
+
25
+ describe('parseAiContentSegments — fenceless recovery', () => {
26
+ it('lifts a fenceless single-line product card out of the prose', () => {
27
+ const content =
28
+ 'Here are the recent products: ' +
29
+ 'open-mercato:product { "id": "c3171e47-9067-424d-8577-6ad25685b69c", "name": "Aurora Wrap Dress", "sku": "AURORA-WRAP", "price": 212, "currency": "USD", "imageUrl": "/api/attachments/image/988b8d93/aurora.png", "href": "/backend/catalog/catalog/products/c3171e47-9067-424d-8577-6ad25685b69c" } ' +
30
+ 'and more.'
31
+ const segments = parseAiContentSegments(content)
32
+ const cards = segments.filter((s) => s.kind === 'record-card')
33
+ expect(cards).toHaveLength(1)
34
+ if (cards[0].kind === 'record-card' && cards[0].payload.kind === 'product') {
35
+ expect(cards[0].payload.name).toBe('Aurora Wrap Dress')
36
+ expect(cards[0].payload.sku).toBe('AURORA-WRAP')
37
+ expect(cards[0].payload.imageUrl).toBe('/api/attachments/image/988b8d93/aurora.png')
38
+ }
39
+ // Surrounding prose is preserved
40
+ const md = segments.filter((s) => s.kind === 'markdown')
41
+ expect(md.length).toBeGreaterThanOrEqual(1)
42
+ })
43
+
44
+ it('lifts multiple fenceless cards in a row', () => {
45
+ const content = [
46
+ 'Products:',
47
+ 'open-mercato:product { "id": "1", "name": "Aurora Wrap Dress" }',
48
+ 'open-mercato:product { "id": "2", "name": "Atlas Runner Sneaker" }',
49
+ 'open-mercato:product { "id": "3", "name": "Restorative Massage" }',
50
+ 'Tell me which one to work on next.',
51
+ ].join('\n')
52
+ const segments = parseAiContentSegments(content)
53
+ const cards = segments.filter((s) => s.kind === 'record-card')
54
+ expect(cards).toHaveLength(3)
55
+ const names = cards
56
+ .map((card) => (card.kind === 'record-card' && card.payload.kind === 'product' ? card.payload.name : null))
57
+ .filter(Boolean)
58
+ expect(names).toEqual(['Aurora Wrap Dress', 'Atlas Runner Sneaker', 'Restorative Massage'])
59
+ })
60
+
61
+ it('lifts a fenceless multi-line card spanning newlines', () => {
62
+ const content =
63
+ 'Here is one:\n' +
64
+ 'open-mercato:product\n' +
65
+ '{\n' +
66
+ ' "id": "abc",\n' +
67
+ ' "name": "Aurora Wrap Dress"\n' +
68
+ '}\n' +
69
+ 'follow-up text.'
70
+ const segments = parseAiContentSegments(content)
71
+ const cards = segments.filter((s) => s.kind === 'record-card')
72
+ expect(cards).toHaveLength(1)
73
+ })
74
+
75
+ it('falls back to plain markdown when the json is invalid', () => {
76
+ const content = 'Maybe: open-mercato:product { not json } end'
77
+ const segments = parseAiContentSegments(content)
78
+ expect(segments.every((s) => s.kind === 'markdown')).toBe(true)
79
+ })
80
+
81
+ it('falls back to plain markdown for unknown kinds', () => {
82
+ const content = 'See: open-mercato:bogus { "id": "x", "name": "y" }'
83
+ const segments = parseAiContentSegments(content)
84
+ expect(segments.every((s) => s.kind === 'markdown')).toBe(true)
85
+ })
86
+
87
+ it('preserves a properly fenced card when both formats appear', () => {
88
+ const content = [
89
+ 'Two products:',
90
+ '```open-mercato:product',
91
+ '{ "id": "1", "name": "Fenced Product" }',
92
+ '```',
93
+ 'and: open-mercato:product { "id": "2", "name": "Fenceless Product" }',
94
+ ].join('\n')
95
+ const segments = parseAiContentSegments(content)
96
+ const cards = segments.filter((s) => s.kind === 'record-card')
97
+ expect(cards).toHaveLength(2)
98
+ const names = cards
99
+ .map((card) => (card.kind === 'record-card' && card.payload.kind === 'product' ? card.payload.name : null))
100
+ .filter(Boolean)
101
+ expect(names).toEqual(['Fenced Product', 'Fenceless Product'])
102
+ })
103
+
104
+ it('handles nested braces inside the json (e.g. attribute objects)', () => {
105
+ const content =
106
+ 'Here: open-mercato:product { "id": "1", "name": "X", "attrs": { "color": "rosewood", "size": "M" } } done'
107
+ const segments = parseAiContentSegments(content)
108
+ const cards = segments.filter((s) => s.kind === 'record-card')
109
+ expect(cards).toHaveLength(1)
110
+ })
111
+ })
@@ -0,0 +1,199 @@
1
+ import {
2
+ RESERVED_AI_UI_PART_IDS,
3
+ createAiUiPartRegistry,
4
+ defaultAiUiPartRegistry,
5
+ listAiUiParts,
6
+ registerAiUiPart,
7
+ resetAiUiPartRegistryForTests,
8
+ resolveAiUiPart,
9
+ unregisterAiUiPart,
10
+ type AiUiPartComponent,
11
+ } from '../ui-part-registry'
12
+ import { PendingPhase3Placeholder } from '../ui-parts/pending-phase3-placeholder'
13
+ import { AI_MUTATION_APPROVAL_CARDS } from '../parts/approval-cards-map'
14
+ import { MutationPreviewCard } from '../parts/MutationPreviewCard'
15
+ import { FieldDiffCard } from '../parts/FieldDiffCard'
16
+ import { ConfirmationCard } from '../parts/ConfirmationCard'
17
+ import { MutationResultCard } from '../parts/MutationResultCard'
18
+
19
+ const NullComponent = (() => null) as unknown as AiUiPartComponent
20
+
21
+ describe('ai-part-registry', () => {
22
+ beforeEach(() => {
23
+ resetAiUiPartRegistryForTests()
24
+ })
25
+
26
+ afterAll(() => {
27
+ resetAiUiPartRegistryForTests()
28
+ })
29
+
30
+ it('resolves the LIVE mutation-approval cards on the default registry (Step 5.10)', () => {
31
+ expect(resolveAiUiPart('mutation-preview-card')).toBe(MutationPreviewCard)
32
+ expect(resolveAiUiPart('field-diff-card')).toBe(FieldDiffCard)
33
+ expect(resolveAiUiPart('confirmation-card')).toBe(ConfirmationCard)
34
+ expect(resolveAiUiPart('mutation-result-card')).toBe(MutationResultCard)
35
+ })
36
+
37
+ it('AI_MUTATION_APPROVAL_CARDS includes every reserved Phase 3 slot id', () => {
38
+ for (const reservedId of RESERVED_AI_UI_PART_IDS) {
39
+ expect(AI_MUTATION_APPROVAL_CARDS[reservedId]).toBeDefined()
40
+ }
41
+ })
42
+
43
+ it('round-trips a registered component, overwriting the seeded placeholder', () => {
44
+ registerAiUiPart('mutation-preview-card', NullComponent)
45
+ expect(resolveAiUiPart('mutation-preview-card')).toBe(NullComponent)
46
+ })
47
+
48
+ it('unregisterAiUiPart removes a registration (including seeded placeholders)', () => {
49
+ unregisterAiUiPart('field-diff-card')
50
+ expect(resolveAiUiPart('field-diff-card')).toBeNull()
51
+ })
52
+
53
+ it('re-registering the same id overwrites the previous entry', () => {
54
+ const first = (() => null) as unknown as AiUiPartComponent
55
+ const second = (() => null) as unknown as AiUiPartComponent
56
+ registerAiUiPart('confirmation-card', first)
57
+ registerAiUiPart('confirmation-card', second)
58
+ expect(resolveAiUiPart('confirmation-card')).toBe(second)
59
+ })
60
+
61
+ it('rejects empty component ids', () => {
62
+ expect(() => registerAiUiPart('', NullComponent)).toThrow()
63
+ })
64
+
65
+ it('ships with the canonical reserved Phase 3 slot ids', () => {
66
+ expect(RESERVED_AI_UI_PART_IDS).toEqual([
67
+ 'mutation-preview-card',
68
+ 'field-diff-card',
69
+ 'confirmation-card',
70
+ 'mutation-result-card',
71
+ ])
72
+ })
73
+
74
+ describe('listAiUiParts()', () => {
75
+ it('returns reserved: true for the four Phase 3 slot ids', () => {
76
+ const entries = listAiUiParts()
77
+ const byId = new Map(entries.map((entry) => [entry.componentId, entry]))
78
+ for (const reserved of RESERVED_AI_UI_PART_IDS) {
79
+ expect(byId.get(reserved)).toEqual({ componentId: reserved, reserved: true })
80
+ }
81
+ })
82
+
83
+ it('returns reserved: false for user-registered components', () => {
84
+ registerAiUiPart('custom-widget', NullComponent)
85
+ const entries = listAiUiParts()
86
+ const entry = entries.find((e) => e.componentId === 'custom-widget')
87
+ expect(entry).toEqual({ componentId: 'custom-widget', reserved: false })
88
+ })
89
+ })
90
+
91
+ describe('defaultAiUiPartRegistry', () => {
92
+ it('exposes the legacy helpers over the same underlying store', () => {
93
+ registerAiUiPart('custom-widget', NullComponent)
94
+ expect(defaultAiUiPartRegistry.has('custom-widget')).toBe(true)
95
+ expect(defaultAiUiPartRegistry.resolve('custom-widget')).toBe(NullComponent)
96
+ })
97
+
98
+ it('clear() empties everything and re-seeds the LIVE approval cards on the default registry', () => {
99
+ registerAiUiPart('custom-widget', NullComponent)
100
+ defaultAiUiPartRegistry.clear()
101
+ expect(defaultAiUiPartRegistry.has('custom-widget')).toBe(false)
102
+ expect(defaultAiUiPartRegistry.resolve('mutation-preview-card')).toBe(
103
+ MutationPreviewCard,
104
+ )
105
+ })
106
+ })
107
+
108
+ describe('createAiUiPartRegistry()', () => {
109
+ it('seeds reserved placeholders by default (scoped isolation preserved)', () => {
110
+ const registry = createAiUiPartRegistry()
111
+ for (const reserved of RESERVED_AI_UI_PART_IDS) {
112
+ expect(registry.resolve(reserved)).toBe(PendingPhase3Placeholder)
113
+ expect(registry.has(reserved)).toBe(true)
114
+ }
115
+ })
116
+
117
+ it('seeds the LIVE approval cards when seedLiveApprovalCards: true', () => {
118
+ const registry = createAiUiPartRegistry({ seedLiveApprovalCards: true })
119
+ expect(registry.resolve('mutation-preview-card')).toBe(MutationPreviewCard)
120
+ expect(registry.resolve('field-diff-card')).toBe(FieldDiffCard)
121
+ expect(registry.resolve('confirmation-card')).toBe(ConfirmationCard)
122
+ expect(registry.resolve('mutation-result-card')).toBe(MutationResultCard)
123
+ })
124
+
125
+ it('does NOT seed placeholders when seedReservedPlaceholders is false', () => {
126
+ const registry = createAiUiPartRegistry({ seedReservedPlaceholders: false })
127
+ for (const reserved of RESERVED_AI_UI_PART_IDS) {
128
+ expect(registry.resolve(reserved)).toBeNull()
129
+ expect(registry.has(reserved)).toBe(false)
130
+ }
131
+ expect(registry.list()).toEqual([])
132
+ })
133
+
134
+ it('registrations on a scoped registry do not leak into the default registry', () => {
135
+ const scoped = createAiUiPartRegistry()
136
+ scoped.register('scoped-widget', NullComponent)
137
+ expect(scoped.has('scoped-widget')).toBe(true)
138
+ expect(defaultAiUiPartRegistry.has('scoped-widget')).toBe(false)
139
+ expect(resolveAiUiPart('scoped-widget')).toBeNull()
140
+ })
141
+
142
+ it('two scoped registries maintain independent state', () => {
143
+ const a = createAiUiPartRegistry()
144
+ const b = createAiUiPartRegistry()
145
+ const first = (() => null) as unknown as AiUiPartComponent
146
+ const second = (() => null) as unknown as AiUiPartComponent
147
+ a.register('mutation-preview-card', first)
148
+ b.register('mutation-preview-card', second)
149
+ expect(a.resolve('mutation-preview-card')).toBe(first)
150
+ expect(b.resolve('mutation-preview-card')).toBe(second)
151
+ })
152
+
153
+ it('register() replaces an existing id on a scoped registry', () => {
154
+ const registry = createAiUiPartRegistry()
155
+ const real = (() => null) as unknown as AiUiPartComponent
156
+ registry.register('mutation-preview-card', real)
157
+ expect(registry.resolve('mutation-preview-card')).toBe(real)
158
+ })
159
+
160
+ it('unregister() removes an entry on a scoped registry', () => {
161
+ const registry = createAiUiPartRegistry()
162
+ registry.unregister('confirmation-card')
163
+ expect(registry.has('confirmation-card')).toBe(false)
164
+ expect(registry.resolve('confirmation-card')).toBeNull()
165
+ })
166
+
167
+ it('clear() wipes registrations and re-seeds reserved ids when seeding is enabled', () => {
168
+ const registry = createAiUiPartRegistry()
169
+ registry.register('custom', NullComponent)
170
+ registry.clear()
171
+ expect(registry.has('custom')).toBe(false)
172
+ for (const reserved of RESERVED_AI_UI_PART_IDS) {
173
+ expect(registry.has(reserved)).toBe(true)
174
+ }
175
+ })
176
+
177
+ it('clear() leaves an un-seeded registry empty', () => {
178
+ const registry = createAiUiPartRegistry({ seedReservedPlaceholders: false })
179
+ registry.register('custom', NullComponent)
180
+ registry.clear()
181
+ expect(registry.list()).toEqual([])
182
+ })
183
+
184
+ it('list() flags reserved entries correctly on scoped registries', () => {
185
+ const registry = createAiUiPartRegistry()
186
+ registry.register('custom-widget', NullComponent)
187
+ const entries = registry.list()
188
+ const byId = new Map(entries.map((entry) => [entry.componentId, entry]))
189
+ expect(byId.get('custom-widget')).toEqual({
190
+ componentId: 'custom-widget',
191
+ reserved: false,
192
+ })
193
+ expect(byId.get('mutation-preview-card')).toEqual({
194
+ componentId: 'mutation-preview-card',
195
+ reserved: true,
196
+ })
197
+ })
198
+ })
199
+ })
@@ -0,0 +1,43 @@
1
+ import {
2
+ RESERVED_AI_UI_PART_IDS,
3
+ isReservedAiUiPartId,
4
+ } from '../ui-part-slots'
5
+
6
+ describe('ui-part-slots', () => {
7
+ it('exposes exactly four reserved Phase 3 slot ids', () => {
8
+ expect(RESERVED_AI_UI_PART_IDS).toHaveLength(4)
9
+ })
10
+
11
+ it('matches the spec §9 / Step 5.10 contract verbatim and in order', () => {
12
+ expect(RESERVED_AI_UI_PART_IDS).toEqual([
13
+ 'mutation-preview-card',
14
+ 'field-diff-card',
15
+ 'confirmation-card',
16
+ 'mutation-result-card',
17
+ ])
18
+ })
19
+
20
+ it('is a readonly tuple — attempting to mutate it is a compile-time error', () => {
21
+ // Runtime assertion — defence-in-depth against accidental spreading or
22
+ // push() operations in consumer code. The tuple is `as const` so this
23
+ // mostly guards against typed-but-cast code paths.
24
+ const sealedLength = RESERVED_AI_UI_PART_IDS.length
25
+ const mutable = RESERVED_AI_UI_PART_IDS as unknown as string[]
26
+ expect(() => mutable.push('bogus')).toThrow()
27
+ expect(RESERVED_AI_UI_PART_IDS.length).toBe(sealedLength)
28
+ })
29
+
30
+ describe('isReservedAiUiPartId()', () => {
31
+ it('returns true for every reserved id', () => {
32
+ for (const reserved of RESERVED_AI_UI_PART_IDS) {
33
+ expect(isReservedAiUiPartId(reserved)).toBe(true)
34
+ }
35
+ })
36
+
37
+ it('returns false for non-reserved ids', () => {
38
+ expect(isReservedAiUiPartId('custom-widget')).toBe(false)
39
+ expect(isReservedAiUiPartId('')).toBe(false)
40
+ expect(isReservedAiUiPartId('mutation-preview-card-variant')).toBe(false)
41
+ })
42
+ })
43
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import {
6
+ uploadAttachmentsForChat,
7
+ type UploadAttachmentsForChatOptions,
8
+ } from '../upload-adapter'
9
+
10
+ function makeFile(name: string, content = 'hello', type = 'text/plain'): File {
11
+ return new File([content], name, { type })
12
+ }
13
+
14
+ function jsonResponse(status: number, body: unknown): Response {
15
+ const payload = typeof body === 'string' ? body : JSON.stringify(body)
16
+ return new Response(payload, {
17
+ status,
18
+ headers: { 'content-type': 'application/json' },
19
+ })
20
+ }
21
+
22
+ describe('uploadAttachmentsForChat', () => {
23
+ it('returns empty result for empty file list without calling fetch', async () => {
24
+ const fetchImpl = jest.fn()
25
+ const result = await uploadAttachmentsForChat([], {
26
+ fetchImpl: fetchImpl as unknown as typeof fetch,
27
+ })
28
+ expect(result.items).toEqual([])
29
+ expect(result.failed).toEqual([])
30
+ expect(fetchImpl).not.toHaveBeenCalled()
31
+ })
32
+
33
+ it('uploads multiple files, preserves order, and uses defaults', async () => {
34
+ let counter = 0
35
+ const seenUrls: string[] = []
36
+ const fetchImpl = jest.fn(async (input: RequestInfo | URL) => {
37
+ seenUrls.push(String(input))
38
+ const id = `att_${counter++}`
39
+ return jsonResponse(200, {
40
+ ok: true,
41
+ item: { id, fileName: `f-${counter}.txt`, fileSize: 5, mimeType: 'text/plain' },
42
+ })
43
+ }) as unknown as typeof fetch
44
+
45
+ const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]
46
+ const result = await uploadAttachmentsForChat(files, {
47
+ fetchImpl,
48
+ })
49
+
50
+ expect(seenUrls).toHaveLength(3)
51
+ expect(seenUrls.every((u) => u === '/api/attachments')).toBe(true)
52
+ expect(result.items).toHaveLength(3)
53
+ expect(result.failed).toEqual([])
54
+ // Items preserve input order via index-aware parsing.
55
+ expect(result.items.map((item) => item.attachmentId)).toEqual(['att_0', 'att_1', 'att_2'])
56
+ })
57
+
58
+ it('captures server rejections in failed[] with a normalized reason', async () => {
59
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
60
+ const form = init?.body as FormData
61
+ const file = form.get('file') as File
62
+ if (file.name === 'big.bin') {
63
+ return jsonResponse(413, { error: 'Attachment exceeds the maximum upload size.' })
64
+ }
65
+ if (file.name === 'bad.exe') {
66
+ return jsonResponse(400, { error: 'File type not allowed' })
67
+ }
68
+ return jsonResponse(200, {
69
+ ok: true,
70
+ item: { id: 'att_ok', fileName: file.name, fileSize: file.size, mimeType: file.type },
71
+ })
72
+ }) as unknown as typeof fetch
73
+
74
+ const files = [makeFile('ok.txt'), makeFile('big.bin'), makeFile('bad.exe')]
75
+ const result = await uploadAttachmentsForChat(files, { fetchImpl })
76
+
77
+ expect(result.items.map((item) => item.fileName)).toEqual(['ok.txt'])
78
+ expect(result.failed).toEqual([
79
+ expect.objectContaining({ fileName: 'big.bin', reason: 'size_exceeded' }),
80
+ expect.objectContaining({ fileName: 'bad.exe', reason: 'mime_rejected' }),
81
+ ])
82
+ })
83
+
84
+ it('maps network errors to reason=network', async () => {
85
+ const fetchImpl = jest.fn(async () => {
86
+ throw new Error('socket closed')
87
+ }) as unknown as typeof fetch
88
+
89
+ const result = await uploadAttachmentsForChat([makeFile('a.txt')], { fetchImpl })
90
+ expect(result.items).toEqual([])
91
+ expect(result.failed).toEqual([
92
+ expect.objectContaining({ fileName: 'a.txt', reason: 'network' }),
93
+ ])
94
+ })
95
+
96
+ it('honours AbortSignal by flagging remaining files as aborted', async () => {
97
+ const controller = new AbortController()
98
+ let started = 0
99
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
100
+ started += 1
101
+ if (started === 1) {
102
+ controller.abort()
103
+ const error = new Error('aborted') as Error & { name: string }
104
+ error.name = 'AbortError'
105
+ throw error
106
+ }
107
+ // Should not reach here for the remaining files.
108
+ return jsonResponse(200, {
109
+ ok: true,
110
+ item: {
111
+ id: 'att',
112
+ fileName: 'ignored',
113
+ fileSize: 1,
114
+ mimeType: 'text/plain',
115
+ },
116
+ })
117
+ }) as unknown as typeof fetch
118
+
119
+ const files = [makeFile('first.txt'), makeFile('second.txt'), makeFile('third.txt')]
120
+ const result = await uploadAttachmentsForChat(files, {
121
+ fetchImpl,
122
+ signal: controller.signal,
123
+ concurrency: 1,
124
+ })
125
+
126
+ expect(result.items).toEqual([])
127
+ expect(result.failed.every((failure) => failure.reason === 'aborted')).toBe(true)
128
+ expect(result.failed.map((failure) => failure.fileName)).toEqual(
129
+ expect.arrayContaining(['first.txt', 'second.txt', 'third.txt']),
130
+ )
131
+ })
132
+
133
+ it('caps in-flight uploads at concurrency (default 3)', async () => {
134
+ let inFlight = 0
135
+ let peak = 0
136
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
137
+ inFlight += 1
138
+ if (inFlight > peak) peak = inFlight
139
+ await new Promise((resolve) => setTimeout(resolve, 5))
140
+ inFlight -= 1
141
+ const form = init?.body as FormData
142
+ const file = form.get('file') as File
143
+ return jsonResponse(200, {
144
+ ok: true,
145
+ item: {
146
+ id: `att_${file.name}`,
147
+ fileName: file.name,
148
+ fileSize: file.size,
149
+ mimeType: 'text/plain',
150
+ },
151
+ })
152
+ }) as unknown as typeof fetch
153
+
154
+ const files = Array.from({ length: 8 }, (_, index) => makeFile(`f-${index}.txt`))
155
+ const result = await uploadAttachmentsForChat(files, { fetchImpl })
156
+
157
+ expect(result.items).toHaveLength(8)
158
+ expect(peak).toBeLessThanOrEqual(3)
159
+ })
160
+
161
+ it('forwards entityType, recordId, and partitionCode to the multipart payload', async () => {
162
+ const captured: FormData[] = []
163
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
164
+ captured.push(init?.body as FormData)
165
+ return jsonResponse(200, {
166
+ ok: true,
167
+ item: {
168
+ id: 'att_fwd',
169
+ fileName: 'x.txt',
170
+ fileSize: 5,
171
+ mimeType: 'text/plain',
172
+ },
173
+ })
174
+ }) as unknown as typeof fetch
175
+
176
+ const options: UploadAttachmentsForChatOptions = {
177
+ fetchImpl,
178
+ entityType: 'ai-chat-draft',
179
+ recordId: 'chat-abc-123',
180
+ partitionCode: 'attachments',
181
+ }
182
+
183
+ await uploadAttachmentsForChat([makeFile('x.txt')], options)
184
+ const form = captured[0]
185
+ expect(form.get('entityId')).toBe('ai-chat-draft')
186
+ expect(form.get('recordId')).toBe('chat-abc-123')
187
+ expect(form.get('partitionCode')).toBe('attachments')
188
+ })
189
+
190
+ it('falls back to a generated recordId when none is supplied and shares it across the batch', async () => {
191
+ const seenRecordIds = new Set<string>()
192
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
193
+ const form = init?.body as FormData
194
+ seenRecordIds.add(String(form.get('recordId')))
195
+ const file = form.get('file') as File
196
+ return jsonResponse(200, {
197
+ ok: true,
198
+ item: {
199
+ id: `att_${file.name}`,
200
+ fileName: file.name,
201
+ fileSize: file.size,
202
+ mimeType: 'text/plain',
203
+ },
204
+ })
205
+ }) as unknown as typeof fetch
206
+
207
+ await uploadAttachmentsForChat([makeFile('a.txt'), makeFile('b.txt')], { fetchImpl })
208
+ expect(seenRecordIds.size).toBe(1)
209
+ const onlyId = [...seenRecordIds][0]
210
+ expect(typeof onlyId).toBe('string')
211
+ expect((onlyId as string).length).toBeGreaterThan(0)
212
+ })
213
+ })