portal-mojo 0.1.0

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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,627 @@
1
+ import {
2
+ useLayoutEffect,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type FormEvent,
9
+ type KeyboardEvent,
10
+ type ReactNode,
11
+ } from 'react';
12
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
13
+ import { MojoError } from '../client/client';
14
+ import type {
15
+ RecordFeedAdapter,
16
+ RecordFeedAuthor,
17
+ FileReference,
18
+ RecordFeedId,
19
+ RecordFeedItem,
20
+ RecordFeedPage,
21
+ } from '../client/record-feed';
22
+ import type { StartFileUploadOptions } from '../client/upload';
23
+ import { AttachmentQueue, type AttachmentQueueState } from './AttachmentQueue';
24
+ import { MarkdownView } from './MarkdownView';
25
+ import { initials, relative } from './format';
26
+
27
+ interface RecordFeedCommonProps {
28
+ variant?: 'compact' | 'bubbles';
29
+ currentUserId?: RecordFeedId | null;
30
+ currentUser?: RecordFeedAuthor;
31
+ className?: string;
32
+ showInput?: boolean;
33
+ placeholder?: string;
34
+ sendLabel?: string;
35
+ emptyLabel?: string;
36
+ ariaLabel?: string;
37
+ /** Extra item content (actions, reference cards, tool summaries). */
38
+ renderAddon?: (item: RecordFeedItem) => ReactNode;
39
+ /** Thinking/streaming state rendered after the item list. */
40
+ pending?: ReactNode;
41
+ disabled?: boolean;
42
+ /** Generic composer slot; controlled feeds remain attachment-agnostic. */
43
+ composerAddon?: ReactNode;
44
+ submitBlocked?: boolean;
45
+ }
46
+
47
+ export interface AdapterRecordFeedProps extends RecordFeedCommonProps {
48
+ adapter: RecordFeedAdapter;
49
+ items?: never;
50
+ onSend?: never;
51
+ isSending?: never;
52
+ error?: never;
53
+ attachmentUpload?: {
54
+ destination: StartFileUploadOptions;
55
+ expectedGroupId: number | null;
56
+ };
57
+ }
58
+
59
+ export interface ControlledRecordFeedProps extends RecordFeedCommonProps {
60
+ adapter?: never;
61
+ items: readonly RecordFeedItem[];
62
+ onSend: (text: string) => void | Promise<void>;
63
+ isSending?: boolean;
64
+ error?: ReactNode;
65
+ attachmentUpload?: never;
66
+ }
67
+
68
+ export type RecordFeedProps = AdapterRecordFeedProps | ControlledRecordFeedProps;
69
+
70
+ interface ComposerProps {
71
+ draft: string;
72
+ setDraft: (value: string) => void;
73
+ onSubmit: (text: string, originalDraft: string) => void;
74
+ busy: boolean;
75
+ disabled: boolean;
76
+ placeholder: string;
77
+ sendLabel: string;
78
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
79
+ addon?: ReactNode;
80
+ submitBlocked?: boolean;
81
+ }
82
+
83
+ function Composer({
84
+ draft,
85
+ setDraft,
86
+ onSubmit,
87
+ busy,
88
+ disabled,
89
+ placeholder,
90
+ sendLabel,
91
+ textareaRef,
92
+ addon,
93
+ submitBlocked = false,
94
+ }: ComposerProps) {
95
+ const draftId = useId();
96
+ useLayoutEffect(() => {
97
+ const input = textareaRef.current;
98
+ if (!input) return;
99
+ input.style.height = 'auto';
100
+ const capped = Math.min(input.scrollHeight, 144);
101
+ input.style.height = `${Math.max(capped, 38)}px`;
102
+ input.style.overflowY = input.scrollHeight > 144 ? 'auto' : 'hidden';
103
+ }, [draft, textareaRef]);
104
+
105
+ const submit = (event?: FormEvent) => {
106
+ event?.preventDefault();
107
+ const text = draft.trim();
108
+ if (!text || busy || disabled) return;
109
+ onSubmit(text, draft);
110
+ };
111
+
112
+ const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
113
+ const native = event.nativeEvent as globalThis.KeyboardEvent;
114
+ if (
115
+ event.key === 'Enter'
116
+ && !event.shiftKey
117
+ && !event.isDefaultPrevented()
118
+ && !event.currentTarget.disabled
119
+ && !native.isComposing
120
+ ) {
121
+ event.preventDefault();
122
+ submit();
123
+ }
124
+ };
125
+
126
+ return (
127
+ <form className="record-feed-composer" onSubmit={submit}>
128
+ {addon}
129
+ <label className="sr-only" htmlFor={draftId}>Add a note</label>
130
+ <textarea
131
+ id={draftId}
132
+ ref={textareaRef}
133
+ className="record-feed-input"
134
+ rows={1}
135
+ value={draft}
136
+ placeholder={placeholder}
137
+ disabled={busy || disabled || submitBlocked}
138
+ onChange={(event) => setDraft(event.target.value)}
139
+ onKeyDown={keyDown}
140
+ />
141
+ <button
142
+ className="record-feed-send"
143
+ type="submit"
144
+ disabled={busy || disabled || submitBlocked || !draft.trim()}
145
+ aria-label={busy ? 'Sending note' : sendLabel}
146
+ >
147
+ <i className={`bi ${busy ? 'bi-arrow-repeat record-feed-spin' : 'bi-send-fill'}`} aria-hidden="true" />
148
+ <span>{busy ? 'Sending…' : sendLabel}</span>
149
+ </button>
150
+ <span className="record-feed-composer-hint">Enter to send · Shift+Enter for a new line</span>
151
+ </form>
152
+ );
153
+ }
154
+
155
+ function Avatar({ item, mine }: { item: RecordFeedItem; mine: boolean }) {
156
+ if (item.kind === 'assistant') {
157
+ return <span className="record-feed-avatar record-feed-avatar-assistant" aria-hidden="true"><i className="bi bi-stars" /></span>;
158
+ }
159
+ if (item.author.avatarUrl) {
160
+ return <img className="record-feed-avatar" src={item.author.avatarUrl} alt="" />;
161
+ }
162
+ return (
163
+ <span className={`record-feed-avatar${mine ? ' record-feed-avatar-mine' : ''}`} aria-hidden="true">
164
+ {initials(item.author.name)}
165
+ </span>
166
+ );
167
+ }
168
+
169
+ function StatusItem({ item }: { item: Extract<RecordFeedItem, { kind: 'status' }> }) {
170
+ return (
171
+ <div className="record-feed-event-content">
172
+ <i className="bi bi-arrow-left-right" aria-hidden="true" />
173
+ <span>Status changed</span>
174
+ <span className="record-feed-status">{item.from ?? 'unknown'}</span>
175
+ <i className="bi bi-arrow-right" aria-hidden="true" />
176
+ <span className="record-feed-status record-feed-status-to">{item.to ?? 'unknown'}</span>
177
+ <time dateTime={wireDateTime(item.created)}>{relative(item.created)}</time>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ function SystemItem({ item }: { item: Extract<RecordFeedItem, { kind: 'system' }> }) {
183
+ return (
184
+ <div className="record-feed-event-content">
185
+ <i className="bi bi-info-circle" aria-hidden="true" />
186
+ {item.event && <span className="record-feed-event-kind">{item.event.replaceAll('_', ' ')}</span>}
187
+ <span className="record-feed-event-note">{item.content || 'System activity'}</span>
188
+ <time dateTime={wireDateTime(item.created)}>{relative(item.created)}</time>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function wireDateTime(value: number | string): string {
194
+ const parsed = typeof value === 'number'
195
+ ? new Date(value < 1e12 ? value * 1000 : value)
196
+ : new Date(value);
197
+ return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString();
198
+ }
199
+
200
+ function FeedItem({
201
+ item,
202
+ variant,
203
+ currentUserId,
204
+ renderAddon,
205
+ }: {
206
+ item: RecordFeedItem;
207
+ variant: 'compact' | 'bubbles';
208
+ currentUserId?: RecordFeedId | null;
209
+ renderAddon?: (item: RecordFeedItem) => ReactNode;
210
+ }) {
211
+ if (item.kind === 'status') {
212
+ return <li className="record-feed-event record-feed-event-status"><StatusItem item={item} /></li>;
213
+ }
214
+ if (item.kind === 'system') {
215
+ return <li className="record-feed-event"><SystemItem item={item} /></li>;
216
+ }
217
+
218
+ const mine = item.kind === 'comment'
219
+ && currentUserId !== null
220
+ && currentUserId !== undefined
221
+ && item.author.id === currentUserId;
222
+ const displayName = item.kind === 'assistant' ? 'AI Agent' : item.author.name;
223
+ const addon = renderAddon?.(item);
224
+
225
+ return (
226
+ <li
227
+ className={[
228
+ 'record-feed-message',
229
+ mine ? 'record-feed-message-mine' : null,
230
+ item.kind === 'assistant' ? 'record-feed-message-assistant' : null,
231
+ item.pending ? 'record-feed-message-pending' : null,
232
+ variant === 'bubbles' ? 'record-feed-message-bubble' : null,
233
+ ].filter(Boolean).join(' ')}
234
+ aria-label={`${displayName}, ${relative(item.created)}${item.pending ? ', sending' : ''}`}
235
+ >
236
+ <Avatar item={item} mine={mine} />
237
+ <div className="record-feed-message-main">
238
+ <div className="record-feed-message-meta">
239
+ <strong>{displayName}</strong>
240
+ {mine && <span className="record-feed-you">You</span>}
241
+ {item.pending && <span className="record-feed-pending-label">Sending…</span>}
242
+ <time dateTime={wireDateTime(item.created)}>{relative(item.created)}</time>
243
+ </div>
244
+ <div className="record-feed-message-body">
245
+ <MarkdownView source={item.content} renderer="client" />
246
+ </div>
247
+ {item.attachment && (
248
+ <div className="record-feed-attachment" aria-label="Attached file">
249
+ <i className="bi bi-paperclip" aria-hidden="true" />
250
+ <span>{item.attachment.filename}</span>
251
+ <small>{item.attachment.content_type}</small>
252
+ </div>
253
+ )}
254
+ {addon && <div className="record-feed-addon">{addon}</div>}
255
+ </div>
256
+ </li>
257
+ );
258
+ }
259
+
260
+ interface FeedFrameProps extends RecordFeedCommonProps {
261
+ items: readonly RecordFeedItem[];
262
+ loading?: boolean;
263
+ loadError?: Error | null;
264
+ onRetry?: () => void;
265
+ hasEarlier?: boolean;
266
+ mutationError?: ReactNode;
267
+ sending: boolean;
268
+ draft: string;
269
+ setDraft: (value: string) => void;
270
+ onSubmit: (text: string, originalDraft: string) => void;
271
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
272
+ ownSendRef: React.MutableRefObject<boolean>;
273
+ composerAddon?: ReactNode;
274
+ submitBlocked?: boolean;
275
+ }
276
+
277
+ function FeedFrame({
278
+ items,
279
+ variant = 'compact',
280
+ currentUserId,
281
+ className,
282
+ showInput = true,
283
+ placeholder = 'Add a note…',
284
+ sendLabel = 'Send',
285
+ emptyLabel = 'No activity yet.',
286
+ ariaLabel = 'Record activity',
287
+ renderAddon,
288
+ pending,
289
+ disabled = false,
290
+ loading = false,
291
+ loadError,
292
+ onRetry,
293
+ hasEarlier = false,
294
+ mutationError,
295
+ sending,
296
+ draft,
297
+ setDraft,
298
+ onSubmit,
299
+ textareaRef,
300
+ ownSendRef,
301
+ composerAddon,
302
+ submitBlocked,
303
+ }: FeedFrameProps) {
304
+ const scrollRef = useRef<HTMLDivElement>(null);
305
+ const nearBottomRef = useRef(true);
306
+ const initialPinRef = useRef(false);
307
+ const signature = useMemo(
308
+ () => items.map((item) => `${item.id}:${item.content.length}:${item.pending ? 1 : 0}`).join('|'),
309
+ [items],
310
+ );
311
+
312
+ useLayoutEffect(() => {
313
+ const scroll = scrollRef.current;
314
+ if (!scroll || loading) return;
315
+ const shouldPin = !initialPinRef.current || ownSendRef.current || nearBottomRef.current;
316
+ initialPinRef.current = true;
317
+ ownSendRef.current = false;
318
+ if (!shouldPin) return;
319
+ const frame = requestAnimationFrame(() => {
320
+ scroll.scrollTop = scroll.scrollHeight;
321
+ nearBottomRef.current = true;
322
+ });
323
+ return () => cancelAnimationFrame(frame);
324
+ }, [signature, Boolean(pending), loading, ownSendRef]);
325
+
326
+ const onScroll = () => {
327
+ const scroll = scrollRef.current;
328
+ if (!scroll) return;
329
+ nearBottomRef.current = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight <= 48;
330
+ };
331
+
332
+ return (
333
+ <section className={`record-feed record-feed-${variant}${className ? ` ${className}` : ''}`} aria-label={ariaLabel}>
334
+ <div
335
+ ref={scrollRef}
336
+ className="record-feed-scroll"
337
+ role="log"
338
+ aria-live="polite"
339
+ aria-relevant="additions text"
340
+ aria-busy={loading || sending || undefined}
341
+ onScroll={onScroll}
342
+ >
343
+ {hasEarlier && <div className="record-feed-earlier">Showing the latest 100 of this record’s activity</div>}
344
+ {loading ? (
345
+ <div className="record-feed-state" role="status"><i className="bi bi-arrow-repeat record-feed-spin" /> Loading activity…</div>
346
+ ) : loadError ? (
347
+ <div className="record-feed-state record-feed-error" role="alert">
348
+ <span>{loadError.message || 'Could not load activity.'}</span>
349
+ {onRetry && <button className="btn btn-compact" type="button" onClick={onRetry}>Retry</button>}
350
+ </div>
351
+ ) : items.length === 0 && !pending ? (
352
+ <div className="record-feed-state">{emptyLabel}</div>
353
+ ) : (
354
+ <ol className="record-feed-list" role="list">
355
+ {items.map((item) => (
356
+ <FeedItem
357
+ key={item.id}
358
+ item={item}
359
+ variant={variant}
360
+ currentUserId={currentUserId}
361
+ renderAddon={renderAddon}
362
+ />
363
+ ))}
364
+ </ol>
365
+ )}
366
+ {pending && <div className="record-feed-thinking" role="status">{pending}</div>}
367
+ </div>
368
+ {mutationError && <div className="record-feed-mutation-error" role="alert">{mutationError}</div>}
369
+ {showInput && (
370
+ <Composer
371
+ draft={draft}
372
+ setDraft={setDraft}
373
+ onSubmit={onSubmit}
374
+ busy={sending}
375
+ disabled={disabled || Boolean(loadError)}
376
+ placeholder={placeholder}
377
+ sendLabel={sendLabel}
378
+ textareaRef={textareaRef}
379
+ addon={composerAddon}
380
+ submitBlocked={submitBlocked}
381
+ />
382
+ )}
383
+ </section>
384
+ );
385
+ }
386
+
387
+ interface MutationContext {
388
+ snapshot: RecordFeedPage | undefined;
389
+ tempId: string;
390
+ startedAt: number;
391
+ }
392
+
393
+ interface SendVariables {
394
+ text: string;
395
+ attachment?: FileReference;
396
+ }
397
+
398
+ function AdapterFeed(props: AdapterRecordFeedProps) {
399
+ const { adapter } = props;
400
+ const queryClient = useQueryClient();
401
+ const [draft, setDraft] = useState('');
402
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
403
+ const ownSendRef = useRef(false);
404
+ // The unsanitized restoration draft stays component-local. It never enters
405
+ // MutationCache or Query cache for sensitive adapters.
406
+ const restoreDraftRef = useRef('');
407
+ const [attachmentState, setAttachmentState] = useState<AttachmentQueueState>({ files: [], busy: false });
408
+ const [attachmentReset, setAttachmentReset] = useState(0);
409
+ const [uncertain, setUncertain] = useState<{ text: string; attachmentId: number | null } | null>(null);
410
+ const adapterScope = JSON.stringify(adapter.queryKey);
411
+ useEffect(() => {
412
+ setDraft('');
413
+ restoreDraftRef.current = '';
414
+ setAttachmentState({ files: [], busy: false });
415
+ setUncertain(null);
416
+ }, [adapterScope]);
417
+ useEffect(() => {
418
+ if (props.showInput !== false && !props.disabled) return;
419
+ setDraft('');
420
+ restoreDraftRef.current = '';
421
+ setAttachmentState({ files: [], busy: false });
422
+ setUncertain(null);
423
+ }, [props.disabled, props.showInput]);
424
+ const query = useQuery({
425
+ queryKey: adapter.queryKey,
426
+ queryFn: () => adapter.fetch(),
427
+ });
428
+ const author = props.currentUser ?? {
429
+ id: props.currentUserId ?? null,
430
+ name: 'You',
431
+ };
432
+
433
+ const mutation = useMutation<RecordFeedItem, Error, SendVariables, MutationContext>({
434
+ mutationFn: ({ text, attachment }) => adapter.addNote({ text, attachment }),
435
+ onMutate: async ({ text, attachment }) => {
436
+ await queryClient.cancelQueries({ queryKey: adapter.queryKey, exact: true });
437
+ const snapshot = queryClient.getQueryData<RecordFeedPage>(adapter.queryKey);
438
+ const tempId = `record-feed-temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
439
+ const startedAt = Math.floor(Date.now() / 1000);
440
+ const temporary: RecordFeedItem = {
441
+ id: tempId,
442
+ kind: 'comment',
443
+ created: Math.floor(Date.now() / 1000),
444
+ content: text,
445
+ author,
446
+ metadata: {},
447
+ raw: {},
448
+ attachment: attachment ?? null,
449
+ pending: true,
450
+ };
451
+ queryClient.setQueryData<RecordFeedPage>(adapter.queryKey, (current) => ({
452
+ items: [...(current?.items ?? []), temporary],
453
+ count: (current?.count ?? 0) + 1,
454
+ hasEarlier: current?.hasEarlier ?? false,
455
+ }));
456
+ return { snapshot, tempId, startedAt };
457
+ },
458
+ onSuccess: (saved, _variables, context) => {
459
+ queryClient.setQueryData<RecordFeedPage>(adapter.queryKey, (current) => {
460
+ if (!current) return { items: [saved], count: 1, hasEarlier: false };
461
+ const found = current.items.some((item) => item.id === context.tempId);
462
+ return {
463
+ ...current,
464
+ items: found
465
+ ? current.items.map((item) => item.id === context.tempId ? saved : item)
466
+ : [...current.items, saved],
467
+ };
468
+ });
469
+ requestAnimationFrame(() => textareaRef.current?.focus());
470
+ setAttachmentReset((value) => value + 1);
471
+ setUncertain(null);
472
+ },
473
+ onError: async (error, variables, context) => {
474
+ if (context?.snapshot) {
475
+ queryClient.setQueryData(adapter.queryKey, context.snapshot);
476
+ } else {
477
+ queryClient.removeQueries({ queryKey: adapter.queryKey, exact: true });
478
+ }
479
+ setDraft(restoreDraftRef.current);
480
+ requestAnimationFrame(() => textareaRef.current?.focus());
481
+ const ambiguous = !(error instanceof MojoError) || error.status <= 0 || error.status >= 500;
482
+ if (!ambiguous || !context) return;
483
+ const pending = { text: variables.text, attachmentId: variables.attachment?.id ?? null };
484
+ setUncertain(pending);
485
+ try {
486
+ const page = await adapter.fetch();
487
+ queryClient.setQueryData(adapter.queryKey, page);
488
+ const previousIds = new Set(context.snapshot?.items.map((item) => item.id) ?? []);
489
+ const saved = page.items.find((item) => !previousIds.has(item.id)
490
+ && wireTimestamp(item.created) >= context.startedAt - 1
491
+ && item.content === variables.text
492
+ && (item.attachment?.id ?? null) === pending.attachmentId);
493
+ if (saved) {
494
+ setDraft('');
495
+ setAttachmentReset((value) => value + 1);
496
+ }
497
+ setUncertain(null);
498
+ } catch {
499
+ // Keep the uncertain state and attachment candidate. Changing
500
+ // the draft/attachment is the explicit escape from blind replay.
501
+ }
502
+ },
503
+ onSettled: () => {
504
+ void queryClient.invalidateQueries({ queryKey: adapter.queryKey, exact: true });
505
+ },
506
+ });
507
+
508
+ const changeDraft = (value: string) => {
509
+ if (mutation.error) mutation.reset();
510
+ if (uncertain && value !== restoreDraftRef.current) setUncertain(null);
511
+ setDraft(value);
512
+ };
513
+ const submit = (text: string, originalDraft: string) => {
514
+ if (mutation.isPending) return;
515
+ ownSendRef.current = true;
516
+ restoreDraftRef.current = originalDraft;
517
+ setDraft('');
518
+ mutation.mutate({
519
+ text: adapter.sanitizeDraft ? adapter.sanitizeDraft(text) : text,
520
+ attachment: attachmentState.files[0],
521
+ });
522
+ };
523
+
524
+ const attachmentUpload = props.attachmentUpload;
525
+ useEffect(() => {
526
+ if (!uncertain) return;
527
+ if ((attachmentState.files[0]?.id ?? null) !== uncertain.attachmentId) setUncertain(null);
528
+ }, [attachmentState.files, uncertain]);
529
+ const attachment = attachmentUpload && props.showInput !== false ? (
530
+ <AttachmentQueue
531
+ key={`${adapter.kind}:${adapter.parentId}:${adapter.groupId ?? 'global'}:${attachmentUpload.destination.fileManagerId ?? 'default'}:${attachmentUpload.destination.groupId ?? 'global'}:${attachmentUpload.destination.use ?? ''}`}
532
+ consumerKey={`record:${adapter.kind}:${adapter.parentId}:${adapter.groupId ?? 'global'}:${attachmentUpload.destination.fileManagerId ?? 'default'}:${attachmentUpload.destination.groupId ?? 'global'}:${attachmentUpload.destination.use ?? ''}`}
533
+ capacity={1}
534
+ destination={attachmentUpload.destination}
535
+ expectedGroupId={attachmentUpload.expectedGroupId}
536
+ disabled={Boolean(props.disabled) || mutation.isPending}
537
+ resetKey={attachmentReset}
538
+ onChange={setAttachmentState}
539
+ />
540
+ ) : null;
541
+
542
+ return (
543
+ <FeedFrame
544
+ {...props}
545
+ items={query.data?.items ?? []}
546
+ loading={query.isPending}
547
+ loadError={query.error}
548
+ onRetry={() => { void query.refetch(); }}
549
+ hasEarlier={query.data?.hasEarlier}
550
+ mutationError={uncertain
551
+ ? 'Delivery could not be confirmed. Refresh the activity, or change the draft or attachment before sending again.'
552
+ : mutation.error?.message}
553
+ sending={mutation.isPending}
554
+ draft={draft}
555
+ setDraft={changeDraft}
556
+ onSubmit={submit}
557
+ textareaRef={textareaRef}
558
+ ownSendRef={ownSendRef}
559
+ composerAddon={attachment}
560
+ submitBlocked={attachmentState.busy || Boolean(uncertain)}
561
+ />
562
+ );
563
+ }
564
+
565
+ function wireTimestamp(value: number | string): number {
566
+ if (typeof value === 'number') return value < 1e12 ? value : Math.floor(value / 1000);
567
+ const parsed = Date.parse(value);
568
+ return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
569
+ }
570
+
571
+ function ControlledFeed(props: ControlledRecordFeedProps) {
572
+ const [draft, setDraft] = useState('');
573
+ const [localSending, setLocalSending] = useState(false);
574
+ const [localError, setLocalError] = useState<Error | null>(null);
575
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
576
+ const ownSendRef = useRef(false);
577
+ const sending = Boolean(props.isSending) || localSending;
578
+
579
+ const changeDraft = (value: string) => {
580
+ setLocalError(null);
581
+ setDraft(value);
582
+ };
583
+ const submit = (text: string, originalDraft: string) => {
584
+ if (sending) return;
585
+ ownSendRef.current = true;
586
+ setDraft('');
587
+ setLocalError(null);
588
+ setLocalSending(true);
589
+ Promise.resolve().then(() => props.onSend(text)).then(
590
+ () => requestAnimationFrame(() => textareaRef.current?.focus()),
591
+ (error: unknown) => {
592
+ setDraft(originalDraft);
593
+ setLocalError(error instanceof Error ? error : new Error(String(error)));
594
+ requestAnimationFrame(() => textareaRef.current?.focus());
595
+ },
596
+ ).finally(() => setLocalSending(false));
597
+ };
598
+
599
+ return (
600
+ <FeedFrame
601
+ {...props}
602
+ items={props.items}
603
+ mutationError={props.error ?? localError?.message}
604
+ sending={sending}
605
+ draft={draft}
606
+ setDraft={changeDraft}
607
+ onSubmit={submit}
608
+ textareaRef={textareaRef}
609
+ ownSendRef={ownSendRef}
610
+ composerAddon={props.composerAddon}
611
+ submitBlocked={props.submitBlocked}
612
+ />
613
+ );
614
+ }
615
+
616
+ /**
617
+ * Record-scoped activity/comment feed. Adapter mode owns Query + exact
618
+ * optimistic cache behavior; controlled mode is ready for streaming owners.
619
+ */
620
+ export function RecordFeed(props: RecordFeedProps) {
621
+ return 'adapter' in props && props.adapter
622
+ // A record/group switch is a new composer and scroll context, not
623
+ // just a new Query. Remounting on the structural key prevents draft,
624
+ // mutation/error, and bottom-pin refs from crossing records.
625
+ ? <AdapterFeed key={JSON.stringify(props.adapter.queryKey)} {...props} />
626
+ : <ControlledFeed {...props as ControlledRecordFeedProps} />;
627
+ }
@@ -0,0 +1,32 @@
1
+ // Guard for group-scoped screens (the `requiresGroup` idea from web-mojo's
2
+ // page config): children render only when an active group is set; otherwise
3
+ // a pick-a-group empty state. UX gating — the server still scopes every query.
4
+ import type { ReactNode } from 'react';
5
+ import { useActiveGroup } from '../client/group';
6
+
7
+ export function RequiresGroup({ fallback, children }: {
8
+ /** Rendered when no group is active (default: a pick-a-group panel). */
9
+ fallback?: ReactNode;
10
+ children: ReactNode;
11
+ }) {
12
+ const { group, loading } = useActiveGroup();
13
+ if (group) return <>{children}</>;
14
+ if (loading) {
15
+ return (
16
+ <div className="panel empty-state">
17
+ <span className="skel skel-block" />
18
+ </div>
19
+ );
20
+ }
21
+ return (
22
+ <>
23
+ {fallback ?? (
24
+ <div className="panel empty-state">
25
+ <i className="bi bi-diagram-3" />
26
+ <h3>Select a group</h3>
27
+ <p className="dim">This screen is group-scoped — pick an active group from the sidebar switcher.</p>
28
+ </div>
29
+ )}
30
+ </>
31
+ );
32
+ }