@pilotiq/pilotiq 0.6.2 → 0.7.1

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 (197) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +614 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Column.d.ts +35 -0
  5. package/dist/Column.d.ts.map +1 -1
  6. package/dist/Column.js +41 -0
  7. package/dist/Column.js.map +1 -1
  8. package/dist/Page.d.ts +13 -4
  9. package/dist/Page.d.ts.map +1 -1
  10. package/dist/Page.js +9 -2
  11. package/dist/Page.js.map +1 -1
  12. package/dist/Pilotiq.d.ts +84 -0
  13. package/dist/Pilotiq.d.ts.map +1 -1
  14. package/dist/Pilotiq.js +66 -0
  15. package/dist/Pilotiq.js.map +1 -1
  16. package/dist/Resource.d.ts +26 -0
  17. package/dist/Resource.d.ts.map +1 -1
  18. package/dist/Resource.js +9 -0
  19. package/dist/Resource.js.map +1 -1
  20. package/dist/actions/exportFactory.js +1 -1
  21. package/dist/actions/exportFactory.js.map +1 -1
  22. package/dist/columns/SelectColumn.d.ts +32 -5
  23. package/dist/columns/SelectColumn.d.ts.map +1 -1
  24. package/dist/columns/SelectColumn.js +37 -7
  25. package/dist/columns/SelectColumn.js.map +1 -1
  26. package/dist/defaultPages.d.ts.map +1 -1
  27. package/dist/defaultPages.js +3 -0
  28. package/dist/defaultPages.js.map +1 -1
  29. package/dist/elements/Form.d.ts +17 -0
  30. package/dist/elements/Form.d.ts.map +1 -1
  31. package/dist/elements/Form.js +17 -0
  32. package/dist/elements/Form.js.map +1 -1
  33. package/dist/elements/Table.d.ts +26 -0
  34. package/dist/elements/Table.d.ts.map +1 -1
  35. package/dist/elements/Table.js +15 -1
  36. package/dist/elements/Table.js.map +1 -1
  37. package/dist/elements/TableGroup.d.ts +84 -0
  38. package/dist/elements/TableGroup.d.ts.map +1 -1
  39. package/dist/elements/TableGroup.js +103 -0
  40. package/dist/elements/TableGroup.js.map +1 -1
  41. package/dist/elements/dispatchForm.d.ts.map +1 -1
  42. package/dist/elements/dispatchForm.js +36 -6
  43. package/dist/elements/dispatchForm.js.map +1 -1
  44. package/dist/elements/dispatchTable.d.ts +12 -0
  45. package/dist/elements/dispatchTable.d.ts.map +1 -1
  46. package/dist/elements/dispatchTable.js +103 -28
  47. package/dist/elements/dispatchTable.js.map +1 -1
  48. package/dist/fields/Field.d.ts +7 -2
  49. package/dist/fields/Field.d.ts.map +1 -1
  50. package/dist/fields/Field.js +8 -3
  51. package/dist/fields/Field.js.map +1 -1
  52. package/dist/fields/RepeaterField.d.ts +65 -0
  53. package/dist/fields/RepeaterField.d.ts.map +1 -1
  54. package/dist/fields/RepeaterField.js +48 -0
  55. package/dist/fields/RepeaterField.js.map +1 -1
  56. package/dist/orm/modelDefaults.d.ts.map +1 -1
  57. package/dist/orm/modelDefaults.js +19 -0
  58. package/dist/orm/modelDefaults.js.map +1 -1
  59. package/dist/pageData.d.ts +20 -0
  60. package/dist/pageData.d.ts.map +1 -1
  61. package/dist/pageData.js +242 -34
  62. package/dist/pageData.js.map +1 -1
  63. package/dist/react/AppShell.d.ts +17 -1
  64. package/dist/react/AppShell.d.ts.map +1 -1
  65. package/dist/react/AppShell.js +34 -3
  66. package/dist/react/AppShell.js.map +1 -1
  67. package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
  68. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
  69. package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
  70. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
  71. package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
  72. package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
  73. package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
  74. package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
  75. package/dist/react/PendingSuggestionsContext.d.ts +153 -0
  76. package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
  77. package/dist/react/PendingSuggestionsContext.js +46 -0
  78. package/dist/react/PendingSuggestionsContext.js.map +1 -0
  79. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  80. package/dist/react/SchemaRenderer.js +312 -39
  81. package/dist/react/SchemaRenderer.js.map +1 -1
  82. package/dist/react/cells/EditableCell.d.ts +8 -0
  83. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  84. package/dist/react/cells/EditableCell.js +6 -2
  85. package/dist/react/cells/EditableCell.js.map +1 -1
  86. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
  87. package/dist/react/fields/CheckboxListInput.js +29 -2
  88. package/dist/react/fields/CheckboxListInput.js.map +1 -1
  89. package/dist/react/fields/ColorInput.d.ts.map +1 -1
  90. package/dist/react/fields/ColorInput.js +28 -2
  91. package/dist/react/fields/ColorInput.js.map +1 -1
  92. package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
  93. package/dist/react/fields/DateTimeInput.js +28 -2
  94. package/dist/react/fields/DateTimeInput.js.map +1 -1
  95. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  96. package/dist/react/fields/FieldShell.js +161 -3
  97. package/dist/react/fields/FieldShell.js.map +1 -1
  98. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  99. package/dist/react/fields/FileUploadInput.js +27 -2
  100. package/dist/react/fields/FileUploadInput.js.map +1 -1
  101. package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
  102. package/dist/react/fields/KeyValueInput.js +33 -2
  103. package/dist/react/fields/KeyValueInput.js.map +1 -1
  104. package/dist/react/fields/RadioInput.d.ts.map +1 -1
  105. package/dist/react/fields/RadioInput.js +28 -2
  106. package/dist/react/fields/RadioInput.js.map +1 -1
  107. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
  108. package/dist/react/fields/SelectFieldInput.js +31 -2
  109. package/dist/react/fields/SelectFieldInput.js.map +1 -1
  110. package/dist/react/fields/SliderInput.d.ts.map +1 -1
  111. package/dist/react/fields/SliderInput.js +26 -2
  112. package/dist/react/fields/SliderInput.js.map +1 -1
  113. package/dist/react/fields/TagsInput.d.ts.map +1 -1
  114. package/dist/react/fields/TagsInput.js +26 -2
  115. package/dist/react/fields/TagsInput.js.map +1 -1
  116. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
  117. package/dist/react/fields/ToggleFieldInput.js +29 -2
  118. package/dist/react/fields/ToggleFieldInput.js.map +1 -1
  119. package/dist/react/index.d.ts +3 -0
  120. package/dist/react/index.d.ts.map +1 -1
  121. package/dist/react/index.js +3 -0
  122. package/dist/react/index.js.map +1 -1
  123. package/dist/routes.d.ts.map +1 -1
  124. package/dist/routes.js +55 -2
  125. package/dist/routes.js.map +1 -1
  126. package/dist/schema/Section.d.ts +16 -0
  127. package/dist/schema/Section.d.ts.map +1 -1
  128. package/dist/schema/Section.js +16 -0
  129. package/dist/schema/Section.js.map +1 -1
  130. package/dist/schema/Wizard.d.ts +45 -0
  131. package/dist/schema/Wizard.d.ts.map +1 -1
  132. package/dist/schema/Wizard.js +50 -0
  133. package/dist/schema/Wizard.js.map +1 -1
  134. package/dist/schema/resolveSchema.d.ts +8 -0
  135. package/dist/schema/resolveSchema.d.ts.map +1 -1
  136. package/dist/schema/resolveSchema.js +70 -1
  137. package/dist/schema/resolveSchema.js.map +1 -1
  138. package/dist/sessionFilters.d.ts.map +1 -1
  139. package/dist/sessionFilters.js +12 -1
  140. package/dist/sessionFilters.js.map +1 -1
  141. package/dist/styles/file-upload.css +13 -0
  142. package/dist/vite.d.ts.map +1 -1
  143. package/dist/vite.js +19 -12
  144. package/dist/vite.js.map +1 -1
  145. package/package.json +6 -4
  146. package/src/Column.test.ts +36 -0
  147. package/src/Column.ts +54 -0
  148. package/src/Page.ts +13 -4
  149. package/src/Pilotiq.ts +109 -0
  150. package/src/Resource.ts +29 -0
  151. package/src/actions/exportFactory.ts +1 -1
  152. package/src/columns/SelectColumn.ts +46 -8
  153. package/src/columns/editableColumns.test.ts +45 -0
  154. package/src/defaultPages.ts +3 -0
  155. package/src/elements/Form.ts +19 -0
  156. package/src/elements/Table.ts +35 -1
  157. package/src/elements/TableGroup.test.ts +111 -0
  158. package/src/elements/TableGroup.ts +135 -0
  159. package/src/elements/dispatchForm.ts +34 -7
  160. package/src/elements/dispatchTable.test.ts +267 -0
  161. package/src/elements/dispatchTable.ts +111 -32
  162. package/src/fields/Field.test.ts +15 -0
  163. package/src/fields/Field.ts +8 -3
  164. package/src/fields/RepeaterField.ts +104 -0
  165. package/src/fields/RepeaterRelationship.test.ts +173 -0
  166. package/src/nestedRelationManagerData.test.ts +21 -0
  167. package/src/orm/modelDefaults.ts +21 -0
  168. package/src/pageData.ts +267 -47
  169. package/src/react/AppShell.tsx +55 -4
  170. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  171. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  172. package/src/react/PendingSuggestionsContext.tsx +172 -0
  173. package/src/react/SchemaRenderer.tsx +504 -95
  174. package/src/react/cells/EditableCell.tsx +11 -2
  175. package/src/react/fields/CheckboxListInput.tsx +23 -2
  176. package/src/react/fields/ColorInput.tsx +22 -2
  177. package/src/react/fields/DateTimeInput.tsx +22 -2
  178. package/src/react/fields/FieldShell.tsx +167 -3
  179. package/src/react/fields/FileUploadInput.tsx +21 -2
  180. package/src/react/fields/KeyValueInput.tsx +32 -2
  181. package/src/react/fields/RadioInput.tsx +23 -2
  182. package/src/react/fields/SelectFieldInput.tsx +25 -2
  183. package/src/react/fields/SliderInput.tsx +20 -2
  184. package/src/react/fields/TagsInput.tsx +20 -2
  185. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  186. package/src/react/index.ts +18 -0
  187. package/src/relationManagerData.test.ts +451 -2
  188. package/src/routes.ts +58 -2
  189. package/src/schema/Section.ts +17 -0
  190. package/src/schema/Wizard.ts +67 -0
  191. package/src/schema/containers.test.ts +90 -0
  192. package/src/schema/resolveSchema.test.ts +50 -0
  193. package/src/schema/resolveSchema.ts +79 -1
  194. package/src/sessionFilters.test.ts +23 -0
  195. package/src/sessionFilters.ts +11 -1
  196. package/src/styles/file-upload.css +13 -0
  197. package/src/vite.ts +19 -12
package/src/pageData.ts CHANGED
@@ -244,6 +244,12 @@ export async function panelInfo(
244
244
  buildRightSidebarMeta(cfg, user),
245
245
  ])
246
246
  const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
247
+ // AI suggestion mode — sparse: omit when 'auto' (the default) so the
248
+ // wire shape stays minimal for panels that don't opt into review mode.
249
+ // Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
250
+ // this to decide whether to apply writes immediately or stage them as
251
+ // PendingSuggestions for user approval.
252
+ const aiSuggestionsMode = pilotiq.getAiSuggestionsMode()
247
253
  return {
248
254
  name: cfg.name,
249
255
  branding: cfg.branding,
@@ -254,6 +260,7 @@ export async function panelInfo(
254
260
  ...(databaseNotifications ? { databaseNotifications } : {}),
255
261
  ...(rightSidebar ? { rightSidebar } : {}),
256
262
  ...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
263
+ ...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
257
264
  }
258
265
  }
259
266
 
@@ -1872,7 +1879,7 @@ export async function resourceEditData(
1872
1879
  // navigation strip so users can drill into each manager's table
1873
1880
  // without leaving the parent record context. The "Edit" tab is
1874
1881
  // active here.
1875
- const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__edit')
1882
+ const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__edit', user, record)
1876
1883
  if (relationTabsEl) elements.unshift(relationTabsEl)
1877
1884
 
1878
1885
  const recordTitle = record !== undefined && record !== null
@@ -2237,7 +2244,7 @@ async function buildRelationListData(
2237
2244
  tagActionDispatch(elements, listUrl)
2238
2245
  await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
2239
2246
 
2240
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
2247
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2241
2248
  if (tabs) elements.unshift(tabs)
2242
2249
 
2243
2250
  const breadcrumbs = relationListBreadcrumbs(
@@ -2313,7 +2320,7 @@ async function buildRelationCreateData(
2313
2320
  if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
2314
2321
  }
2315
2322
 
2316
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
2323
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2317
2324
  if (tabs) elements.unshift(tabs)
2318
2325
 
2319
2326
  const breadcrumbs = relationCreateBreadcrumbs(
@@ -2406,15 +2413,16 @@ async function buildRelationViewData(
2406
2413
  // view straight into a grandchild list / create / view / edit page.
2407
2414
  // Active key `'__view'` because the user is currently viewing the
2408
2415
  // leaf parent record itself, not any nested manager.
2409
- const nestedTabs = buildNestedRelationTabs(
2416
+ const nestedTabs = await buildNestedRelationTabs(
2410
2417
  R, M, base,
2411
2418
  { recordId: scope.recordId, relationship: scope.relationship },
2412
2419
  scope.childId,
2413
2420
  '__view',
2421
+ user, child,
2414
2422
  )
2415
2423
  if (nestedTabs) elements.unshift(nestedTabs)
2416
2424
 
2417
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
2425
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2418
2426
  if (tabs) elements.unshift(tabs)
2419
2427
 
2420
2428
  const breadcrumbs = relationViewBreadcrumbs(
@@ -2524,7 +2532,7 @@ async function buildRelationEditData(
2524
2532
  form.withValues(values)
2525
2533
  }
2526
2534
 
2527
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
2535
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2528
2536
  if (tabs) elements.unshift(tabs)
2529
2537
 
2530
2538
  const breadcrumbs = relationEditBreadcrumbs(
@@ -2844,7 +2852,7 @@ async function buildNestedRelationListData(
2844
2852
  tagActionDispatch(elements, listUrl)
2845
2853
  await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
2846
2854
 
2847
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
2855
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
2848
2856
  if (tabs) elements.unshift(tabs)
2849
2857
 
2850
2858
  const breadcrumbs = nestedRelationListBreadcrumbs(
@@ -2898,7 +2906,7 @@ async function buildNestedRelationCreateData(
2898
2906
  if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
2899
2907
  }
2900
2908
 
2901
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
2909
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
2902
2910
  if (tabs) elements.unshift(tabs)
2903
2911
 
2904
2912
  const breadcrumbs = nestedRelationCreateBreadcrumbs(
@@ -2963,7 +2971,7 @@ async function buildNestedRelationViewData(
2963
2971
 
2964
2972
  const elements: Element[] = M2.detail(child2, child1)
2965
2973
 
2966
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
2974
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
2967
2975
  if (tabs) elements.unshift(tabs)
2968
2976
 
2969
2977
  const breadcrumbs = nestedRelationViewBreadcrumbs(
@@ -3045,7 +3053,7 @@ async function buildNestedRelationEditData(
3045
3053
  form.withValues(values)
3046
3054
  }
3047
3055
 
3048
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
3056
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
3049
3057
  if (tabs) elements.unshift(tabs)
3050
3058
 
3051
3059
  const breadcrumbs = nestedRelationEditBreadcrumbs(
@@ -3092,21 +3100,38 @@ async function buildNestedRelationEditData(
3092
3100
  * absent that, callers skip the prepend so single-manager surfaces stay
3093
3101
  * clean. `activeKey` accepts the literal `'__view'` for the leaf
3094
3102
  * parent's view tab, or any sibling manager's relationship key.
3103
+ *
3104
+ * Per-tab `canX` gating (2026-05-11) — sibling nested-manager tabs run
3105
+ * `N.canViewAny(user, child1Record)` (with fall-through to the related
3106
+ * Resource via `safeManagerPolicy`) so the strip hides tabs the user
3107
+ * couldn't reach anyway. The back-link `__view` stays unconditional
3108
+ * since the user is already on a page scoped under `M.canViewAny` —
3109
+ * they reached this strip, they can navigate back to it.
3095
3110
  */
3096
- function buildNestedRelationTabs(
3097
- R: ResourceClass,
3098
- M: typeof RelationManager,
3099
- basePath: string,
3100
- step0: RelationChainStep,
3101
- child1Id: string,
3102
- activeKey: string,
3103
- ): RelationTabs | undefined {
3111
+ async function buildNestedRelationTabs(
3112
+ R: ResourceClass,
3113
+ M: typeof RelationManager,
3114
+ basePath: string,
3115
+ step0: RelationChainStep,
3116
+ child1Id: string,
3117
+ activeKey: string,
3118
+ user: unknown,
3119
+ child1Record: unknown,
3120
+ ): Promise<RelationTabs | undefined> {
3104
3121
  const siblings = M.relations()
3105
3122
  if (siblings.length === 0) return undefined
3106
3123
 
3107
3124
  const resourceBase = resourceBasePath(basePath, R)
3108
3125
  const parentBase = `${resourceBase}/${step0.recordId}/${step0.relationship}`
3109
3126
 
3127
+ // Sibling gating runs in parallel — each predicate may hit auth /
3128
+ // db, so don't serialize them.
3129
+ const siblingGates = siblings.map(N => {
3130
+ const Related = (N as unknown as { relatedResource?: ResourceClass }).relatedResource
3131
+ return safeManagerPolicyImpl(N, 'canViewAny', Related, user, child1Record)
3132
+ })
3133
+ const siblingVisible = await Promise.all(siblingGates)
3134
+
3110
3135
  const tabs: RelationTabMeta[] = []
3111
3136
 
3112
3137
  // Back-link: depth-2 view page for the leaf parent record. Acts as
@@ -3120,9 +3145,10 @@ function buildNestedRelationTabs(
3120
3145
  iconOwner: M.name,
3121
3146
  }))
3122
3147
 
3123
- for (const N of siblings) {
3148
+ siblings.forEach((N, i) => {
3149
+ if (!siblingVisible[i]) return
3124
3150
  let nestedRel = ''
3125
- try { nestedRel = N.getRelationship() } catch { continue }
3151
+ try { nestedRel = N.getRelationship() } catch { return }
3126
3152
  const icon = N.getIcon()
3127
3153
  tabs.push(relationTab({
3128
3154
  key: nestedRel,
@@ -3131,7 +3157,12 @@ function buildNestedRelationTabs(
3131
3157
  active: activeKey === nestedRel,
3132
3158
  ...(icon !== undefined ? { icon, iconOwner: N.name } : {}),
3133
3159
  }))
3134
- }
3160
+ })
3161
+
3162
+ // After gating, only the back-link may remain — one tab isn't a
3163
+ // useful sub-nav. Drop the strip in that case (consistent with
3164
+ // depth-1's empty-tabs branch).
3165
+ if (tabs.length <= 1) return undefined
3135
3166
 
3136
3167
  return RelationTabs.make(tabs)
3137
3168
  }
@@ -3144,11 +3175,25 @@ function buildNestedRelationTabs(
3144
3175
  * the manager's relationship key for a manager tab.
3145
3176
  *
3146
3177
  * Sub-nav follow-up (2026-05-03 cont'd) — emit BOTH `__view` and
3147
- * `__edit` as sibling tabs (Filament-style record sub-navigation)
3148
- * instead of one parent tab whose label depends on mode. Tabs are
3149
- * dropped when the corresponding page role isn't registered (a
3150
- * Resource overriding `pages()` to omit `view` or `edit` shouldn't
3151
- * surface a tab that 404s).
3178
+ * `__edit` as sibling tabs (record sub-navigation) instead of one
3179
+ * parent tab whose label depends on mode. Tabs are dropped when the
3180
+ * corresponding page role isn't registered (a Resource overriding
3181
+ * `pages()` to omit `view` or `edit` shouldn't surface a tab that
3182
+ * 404s).
3183
+ *
3184
+ * Per-tab `canX` gating (2026-05-11) — the strip now also evaluates
3185
+ * the matching predicate for each tab and drops the entry when the
3186
+ * user can't reach it. Routes still enforce; this is presentation
3187
+ * polish so the chrome doesn't promise a link that 403s on click.
3188
+ *
3189
+ * - `__view` → `R.canView(user, parentRecord)` (skip gating when
3190
+ * `parentRecord` is undefined — record load failed,
3191
+ * so the route's own load+gate will surface a 404/403
3192
+ * rather than the strip hiding silently).
3193
+ * - `__edit` → `R.canEdit(user, parentRecord)` (same posture).
3194
+ * - manager → `safeManagerPolicy(M, 'canViewAny', Related, user,
3195
+ * parentRecord)` (falls through to Related's
3196
+ * `canViewAny` when the manager hasn't overridden).
3152
3197
  *
3153
3198
  * Returns `undefined` when the resource has no relation managers — the
3154
3199
  * caller can then skip the prepend entirely so resources without
@@ -3156,23 +3201,65 @@ function buildNestedRelationTabs(
3156
3201
  * (View+Edit sub-nav alone isn't worth a tab strip; users navigate
3157
3202
  * those via headerActions or the back link.)
3158
3203
  */
3159
- function buildRelationTabs(
3160
- R: ResourceClass,
3161
- recordId: string,
3162
- basePath: string,
3163
- activeKey: string,
3164
- ): RelationTabs | undefined {
3165
- const managers = R.relations()
3166
- if (managers.length === 0) return undefined
3204
+ async function buildRelationTabs(
3205
+ R: ResourceClass,
3206
+ recordId: string,
3207
+ basePath: string,
3208
+ activeKey: string,
3209
+ user: unknown,
3210
+ parentRecord: unknown,
3211
+ ): Promise<RelationTabs | undefined> {
3212
+ const managers = R.relations()
3213
+ const recordPageMap = R.getRecordPages()
3214
+ const recordPageSlugs = Object.keys(recordPageMap)
3215
+ // No managers AND no record sub-pages → no strip. View+Edit alone
3216
+ // isn't worth a tab strip; users navigate those via headerActions or
3217
+ // the back link. (When either is non-empty, the strip is worth
3218
+ // mounting even if all the dynamic tabs end up gated away — the
3219
+ // post-gate emptiness check below catches that.)
3220
+ if (managers.length === 0 && recordPageSlugs.length === 0) return undefined
3167
3221
 
3168
3222
  const resourceBase = resourceBasePath(basePath, R)
3169
3223
  const pages = R.resolvePages()
3224
+
3225
+ // Evaluate every per-tab predicate in parallel. The arrays line up
3226
+ // 1:1 with `pages.view` / `pages.edit` / `recordPageSlugs` /
3227
+ // `managers` below — we resolve all gates first so the tab-build
3228
+ // loop stays straight-line.
3229
+ // Record-aware predicates short-circuit to `true` when no parent
3230
+ // record was loaded (presentation should never hide more aggressively
3231
+ // than the route can enforce; a missing record means the route will
3232
+ // 404/403 on click and the strip stays consistent with that).
3233
+ const canViewPromise = pages.view && parentRecord !== undefined && parentRecord !== null
3234
+ ? safeBool(() => R.canView(user, parentRecord))
3235
+ : Promise.resolve(true)
3236
+ const canEditPromise = pages.edit && parentRecord !== undefined && parentRecord !== null
3237
+ ? safeBool(() => R.canEdit(user, parentRecord))
3238
+ : Promise.resolve(true)
3239
+ const recordPageGates = recordPageSlugs.map(subSlug => {
3240
+ // Record sub-page gates run against the parent record verbatim —
3241
+ // missing record still calls the predicate so a sub-page that
3242
+ // gates on global user state (no record needed) still evaluates.
3243
+ // safeBool fails closed for throwing predicates.
3244
+ return safeBool(() => recordPageMap[subSlug]!.canAccess(user, parentRecord))
3245
+ })
3246
+ const managerGates = managers.map(M => {
3247
+ const Related = (M as unknown as { relatedResource?: ResourceClass }).relatedResource
3248
+ return safeManagerPolicyImpl(M, 'canViewAny', Related, user, parentRecord)
3249
+ })
3250
+ const gateResults = await Promise.all([
3251
+ canViewPromise, canEditPromise,
3252
+ ...recordPageGates,
3253
+ ...managerGates,
3254
+ ])
3255
+ const canView = gateResults[0] as boolean
3256
+ const canEdit = gateResults[1] as boolean
3257
+ const recordPageVisible = gateResults.slice(2, 2 + recordPageSlugs.length) as boolean[]
3258
+ const managerVisible = gateResults.slice(2 + recordPageSlugs.length) as boolean[]
3259
+
3170
3260
  const tabs: RelationTabMeta[] = []
3171
3261
 
3172
- // View tab only when the resource has a ViewPage registered.
3173
- // Defaults always include one; users who pruned ViewPage in their
3174
- // `static pages()` override get no broken link.
3175
- if (pages.view) {
3262
+ if (pages.view && canView) {
3176
3263
  tabs.push(relationTab({
3177
3264
  key: '__view',
3178
3265
  label: 'View',
@@ -3183,8 +3270,7 @@ function buildRelationTabs(
3183
3270
  }))
3184
3271
  }
3185
3272
 
3186
- // Edit tab same defensive check.
3187
- if (pages.edit) {
3273
+ if (pages.edit && canEdit) {
3188
3274
  tabs.push(relationTab({
3189
3275
  key: '__edit',
3190
3276
  label: 'Edit',
@@ -3198,9 +3284,29 @@ function buildRelationTabs(
3198
3284
  }))
3199
3285
  }
3200
3286
 
3201
- for (const M of managers) {
3287
+ // Record sub-page tabs — between Edit and the managers, in declaration
3288
+ // order. Tab label inherits from the sub-page's class (`getLabel()`);
3289
+ // icon picks up the sub-page's static `icon` when set. Slug doubles as
3290
+ // the URL segment AND the `activeKey` discriminator the data builder
3291
+ // passes when rendering the sub-page.
3292
+ recordPageSlugs.forEach((subSlug, i) => {
3293
+ if (!recordPageVisible[i]) return
3294
+ const SubPage = recordPageMap[subSlug]!
3295
+ tabs.push(relationTab({
3296
+ key: subSlug,
3297
+ label: SubPage.getLabel(),
3298
+ url: `${resourceBase}/${recordId}/${subSlug}`,
3299
+ active: activeKey === subSlug,
3300
+ ...(SubPage.icon !== undefined
3301
+ ? { icon: SubPage.icon, iconOwner: SubPage.name }
3302
+ : {}),
3303
+ }))
3304
+ })
3305
+
3306
+ managers.forEach((M, i) => {
3307
+ if (!managerVisible[i]) return
3202
3308
  let rel = ''
3203
- try { rel = M.getRelationship() } catch { continue }
3309
+ try { rel = M.getRelationship() } catch { return }
3204
3310
  const icon = M.getIcon()
3205
3311
  tabs.push(relationTab({
3206
3312
  key: rel,
@@ -3209,11 +3315,25 @@ function buildRelationTabs(
3209
3315
  active: activeKey === rel,
3210
3316
  ...(icon !== undefined ? { icon, iconOwner: M.name } : {}),
3211
3317
  }))
3212
- }
3318
+ })
3319
+
3320
+ // After gating, the strip may collapse to zero entries. Mirror the
3321
+ // "no managers + no sub-pages" branch above — no strip is friendlier
3322
+ // than a one-tab strip with just the active page.
3323
+ if (tabs.length === 0) return undefined
3213
3324
 
3214
3325
  return RelationTabs.make(tabs)
3215
3326
  }
3216
3327
 
3328
+ /**
3329
+ * Tiny shim over `try { Boolean(await fn()) } catch { false }` so the
3330
+ * relation-tabs builder stays straight-line — mirrors `checkPolicy`
3331
+ * in `routes.ts` but kept local to avoid cross-module imports.
3332
+ */
3333
+ async function safeBool(fn: () => boolean | Promise<boolean>): Promise<boolean> {
3334
+ try { return Boolean(await fn()) } catch { return false }
3335
+ }
3336
+
3217
3337
  /** Pull a human-readable title off a parent record for breadcrumb /
3218
3338
  * page-title use. Falls back through `recordTitleAttribute` →
3219
3339
  * `name` → `title` → primary key value → 'Record'. */
@@ -4178,7 +4298,7 @@ export async function resourceViewData(
4178
4298
 
4179
4299
  // Plan #11 — prepend the relation tabs strip with the "Details" tab
4180
4300
  // active when the resource has relation managers configured.
4181
- const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__view')
4301
+ const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__view', user, record)
4182
4302
  if (relationTabsEl) elements.unshift(relationTabsEl)
4183
4303
 
4184
4304
  const recordTitle = record !== undefined && record !== null
@@ -4210,6 +4330,102 @@ export async function resourceViewData(
4210
4330
  }
4211
4331
  }
4212
4332
 
4333
+ /**
4334
+ * Custom record sub-page data builder. Mounted at
4335
+ * `${resourceBase}/${slug}/:id/${subPageSlug}` for each entry in
4336
+ * `Resource.pages().record`. Mirrors `resourceViewData`'s shape: load
4337
+ * the record, run R.canAccess + R.canView (parent-resource gates),
4338
+ * then SubPage.canAccess(user, record) (sub-page-specific gate),
4339
+ * then render the sub-page's schema with `ctx.record` set. Tab strip
4340
+ * carries the sub-page slug as the active key so the matching record
4341
+ * sub-page tab highlights.
4342
+ *
4343
+ * Returns:
4344
+ * - `null` — resource / sub-page slug not found (404 upstream).
4345
+ * - `{ ok: false, status: 403 }` — any gate fails or throws.
4346
+ * - resolved page data — on success.
4347
+ */
4348
+ export async function resourceRecordPageData(
4349
+ pilotiq: Pilotiq,
4350
+ slug: string,
4351
+ recordId: string,
4352
+ subPageSlug: string,
4353
+ req?: unknown,
4354
+ ): Promise<Record<string, unknown> | null | { ok: false; status: 403 }> {
4355
+ const cfg = pilotiq.getConfig()
4356
+ const R = cfg.resources.find(r => r.getSlug() === slug)
4357
+ if (!R) return null
4358
+ const recordPages = R.getRecordPages()
4359
+ const PageClass = recordPages[subPageSlug]
4360
+ if (!PageClass) return null
4361
+
4362
+ const user = await pilotiq.resolveUser(req)
4363
+
4364
+ // Load the parent record before gating so canView / SubPage.canAccess
4365
+ // can branch on record state. Sub-pages without a Resource.model
4366
+ // still get gated against an `undefined` record — the same posture as
4367
+ // resourceViewData when no model is bound.
4368
+ let record: unknown = undefined
4369
+ if (R.model) {
4370
+ try { record = await findRecord(R, recordId, { user }) } catch { /* ignore */ }
4371
+ }
4372
+ if (record === undefined || record === null) {
4373
+ // Distinguish "model bound but record missing" (route should 404)
4374
+ // from "no model bound" (treat record as `{ id: recordId }` so the
4375
+ // page can still render — same convention as the edit page).
4376
+ if (R.model) return null
4377
+ record = { id: recordId }
4378
+ }
4379
+
4380
+ // Three gates: parent resource access + view, then the sub-page's own
4381
+ // canAccess. The route would have run R.canAccess upstream, but
4382
+ // re-running here makes resourceRecordPageData safe to call from
4383
+ // dispatchPageData (where the SPA path skips the route prelude).
4384
+ if (!await safeBool(() => R.canAccess(user))) return { ok: false, status: 403 }
4385
+ if (!await safeBool(() => R.canView(user, record))) return { ok: false, status: 403 }
4386
+ if (!await safeBool(() => PageClass.canAccess(user, record))) return { ok: false, status: 403 }
4387
+
4388
+ const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg)
4389
+ const elements = await callPageSchema(PageClass, ctx)
4390
+
4391
+ // Insert the relation-tabs strip with the sub-page slug active so the
4392
+ // matching tab highlights. `buildRelationTabs` evaluates per-tab
4393
+ // gating against `user + record` — record sub-page tabs are gated
4394
+ // alongside __view/__edit/managers.
4395
+ const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, subPageSlug, user, record)
4396
+ if (relationTabsEl) elements.unshift(relationTabsEl)
4397
+
4398
+ const recordTitle = record !== undefined && record !== null
4399
+ ? deriveParentTitle(R, record)
4400
+ : recordId
4401
+ const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle)
4402
+ if (breadcrumbs) elements.unshift(breadcrumbs)
4403
+
4404
+ const recordPageRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
4405
+ const schemaData = await applyRoleHooks(
4406
+ pilotiq, user, 'view',
4407
+ await resolveSchema(
4408
+ elements,
4409
+ record !== undefined ? { ...ctx, record } : ctx,
4410
+ ),
4411
+ recordPageRoute,
4412
+ )
4413
+
4414
+ return {
4415
+ pageType: 'record-page' as const,
4416
+ panel: await panelInfo(pilotiq, req, recordPageRoute),
4417
+ page: PageClass.toMeta(),
4418
+ resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
4419
+ mode: 'record' as const,
4420
+ recordId,
4421
+ subPage: { slug: subPageSlug, label: PageClass.getLabel() },
4422
+ basePath: cfg.path,
4423
+ layout: cfg.layout,
4424
+ schemaData,
4425
+ notifications: consumeFlashedNotifications(req),
4426
+ }
4427
+ }
4428
+
4213
4429
  export async function globalEditData(
4214
4430
  pilotiq: Pilotiq,
4215
4431
  slug: string,
@@ -4606,9 +4822,13 @@ export async function dispatchPageData(pageContext: PageContextLike): Promise<un
4606
4822
  })
4607
4823
  // Tagged failure shapes (`{ ok: false, status: 403 }`) leak straight
4608
4824
  // through to the +Page renderer, which can branch on the shape.
4609
- // For Plan #11 we let null short-circuit the SPA render the same
4610
- // way the resource builders do.
4611
- return out === null ? null : (out as Record<string, unknown>)
4825
+ // null = no manager named `relationship` on R; fall through to the
4826
+ // record sub-page lookup so URLs like `/admin/users/u1/activity`
4827
+ // (where `activity` is registered under `pages().record`) route
4828
+ // through `resourceRecordPageData` rather than 404ing.
4829
+ if (out !== null) return out as Record<string, unknown>
4830
+ const recordOut = await resourceRecordPageData(panel, slug, id, relationship)
4831
+ return recordOut === null ? null : (recordOut as Record<string, unknown>)
4612
4832
  }
4613
4833
 
4614
4834
  case '/pages/(pilotiq)/relation-create': {
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react'
1
+ import React, { useEffect, useState } from 'react'
2
2
  import { SidebarLayout } from './layouts/SidebarLayout.js'
3
3
  import { TopbarLayout } from './layouts/TopbarLayout.js'
4
4
  import { ToasterProvider } from './Toaster.js'
@@ -38,6 +38,11 @@ export interface AppShellProps {
38
38
  * `panelInfo()` server-side. */
39
39
  renderHooks?: RenderHookMap
40
40
  themeEditor?: boolean
41
+ /** AI suggestion mode — absent means `'auto'` (the default). When
42
+ * set to `'review'`, AI plugins read this and stage writes as
43
+ * `PendingSuggestion`s for user approval instead of applying
44
+ * immediately. Plan: `docs/plans/ai-review-mode.md`. */
45
+ aiSuggestionsMode?: 'auto' | 'review'
41
46
  }
42
47
  basePath: string
43
48
  /** Pathname used to compute active-link state in the sidebar/topbar. */
@@ -60,14 +65,32 @@ export interface AppShellProps {
60
65
  * chrome simply doesn't mount.
61
66
  */
62
67
  rightPanelRegistry?: RightPanelRegistry
68
+ /**
69
+ * Build-time layout-provider registry from the Vite plugin. Each entry
70
+ * is a React component that wraps the panel's layout tree at the
71
+ * root. Plugins register via `Pilotiq.layoutProvider(C)`; the Vite
72
+ * plugin harvests refs into this array. Empty `[]` is the no-op
73
+ * default — chrome renders without any extra wrapping.
74
+ */
75
+ layoutProviderRegistry?: ReadonlyArray<React.ComponentType<{ children: React.ReactNode; basePath?: string }>>
63
76
  children: React.ReactNode
64
77
  }
65
78
 
66
- export function AppShell({ layout = 'sidebar', notifications, componentRegistry, rightPanelRegistry, ...props }: AppShellProps) {
79
+ export function AppShell({ layout = 'sidebar', notifications, componentRegistry, rightPanelRegistry, layoutProviderRegistry, ...props }: AppShellProps) {
67
80
  const Layout = layout === 'topbar' ? TopbarLayout : SidebarLayout
68
81
  // exactOptionalPropertyTypes: only spread `initialNotifications` when set.
69
82
  const toasterProps = notifications ? { initialNotifications: notifications } : {}
70
83
 
84
+ // Stamp the panel-wide AI suggestion mode on a window global so the
85
+ // AI plugin's `update_form_state` client-tool handler can read it
86
+ // without context plumbing. Singleton flag — doesn't change between
87
+ // pages within the same panel. Plan: `docs/plans/ai-review-mode.md`.
88
+ const aiSuggestionsMode = props.panel.aiSuggestionsMode ?? 'auto'
89
+ useEffect(() => {
90
+ if (typeof window === 'undefined') return
91
+ ;(window as unknown as { __pilotiqAiSuggestionsMode?: 'auto' | 'review' }).__pilotiqAiSuggestionsMode = aiSuggestionsMode
92
+ }, [aiSuggestionsMode])
93
+
71
94
  // Plan #12 — palette open state lives at AppShell so the trigger pill
72
95
  // (rendered inside the layout's header) and the palette dialog both
73
96
  // observe the same flag via context.
@@ -106,7 +129,11 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
106
129
  </ToasterProvider>
107
130
  )
108
131
 
109
- return (
132
+ // Plugin-registered layout providers (e.g. AI chat queue, tenant
133
+ // theme switcher). Wraps in registration order: the FIRST registered
134
+ // provider sits OUTERMOST (closest to the layout root); LAST sits
135
+ // INNERMOST (closest to the page tree). Empty / unset → no wrap.
136
+ const wrapped = wrapInLayoutProviders(
110
137
  <ComponentRegistryProvider value={componentRegistry}>
111
138
  <RightPanelRegistryProvider value={rightPanelRegistry}>
112
139
  {rightSidebarMeta ? (
@@ -117,8 +144,32 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
117
144
  inner
118
145
  )}
119
146
  </RightPanelRegistryProvider>
120
- </ComponentRegistryProvider>
147
+ </ComponentRegistryProvider>,
148
+ layoutProviderRegistry,
149
+ props.basePath,
121
150
  )
151
+
152
+ return wrapped
153
+ }
154
+
155
+ /**
156
+ * Fold registered layout providers around `tree` from last to first so
157
+ * the first-registered provider ends up outermost in the React tree
158
+ * (closest to the layout root) — matches the registration-order
159
+ * intuition documented on `Pilotiq.layoutProvider`.
160
+ */
161
+ function wrapInLayoutProviders(
162
+ tree: React.ReactElement,
163
+ registry: ReadonlyArray<React.ComponentType<{ children: React.ReactNode; basePath?: string }>> | undefined,
164
+ basePath: string,
165
+ ): React.ReactElement {
166
+ if (!registry || registry.length === 0) return tree
167
+ let acc: React.ReactElement = tree
168
+ for (let i = registry.length - 1; i >= 0; i--) {
169
+ const Provider = registry[i]!
170
+ acc = <Provider basePath={basePath}>{acc}</Provider>
171
+ }
172
+ return acc
122
173
  }
123
174
 
124
175
  /**
@@ -0,0 +1,80 @@
1
+ import type { PendingSuggestion } from './PendingSuggestionsContext.js'
2
+
3
+ /**
4
+ * A function that applies a `PendingSuggestion` to its target field —
5
+ * registered by the field renderer (or editor adapter) on mount, looked
6
+ * up by aggregate consumers (e.g. a chat-sidebar pending-pill's
7
+ * "Approve all" button) that live outside the form's React tree.
8
+ *
9
+ * The applier is responsible for the apply *only*. Dismissing the
10
+ * suggestion from the queue is the caller's job — the apply path is
11
+ * decoupled from the queue-side bookkeeping so a future Phase that
12
+ * mirrors approvals to a server can do both via different code paths.
13
+ */
14
+ export type PendingSuggestionApplier = (suggestion: PendingSuggestion) => void
15
+
16
+ interface RegistryEntry {
17
+ formId: string | undefined
18
+ fieldName: string
19
+ apply: PendingSuggestionApplier
20
+ }
21
+
22
+ const _entries = new Map<string, RegistryEntry>()
23
+
24
+ /**
25
+ * Compose the registry key. `formId` defaults to `'*'` (global form
26
+ * scope) so renderers in non-multi-form pages don't have to thread an
27
+ * id. Form-scoped registrations always win over the wildcard when both
28
+ * exist for the same field name.
29
+ */
30
+ function keyFor(formId: string | undefined, fieldName: string): string {
31
+ return `${formId ?? '*'}::${fieldName}`
32
+ }
33
+
34
+ /**
35
+ * Register an applier for `(formId, fieldName)`. Returns an unregister
36
+ * function for `useEffect` cleanup. Re-registering with the same key
37
+ * replaces the previous entry — the most recently mounted renderer
38
+ * wins (typical in multi-instance form scenarios where an old form
39
+ * unmounts after a new one mounts during navigation).
40
+ */
41
+ export function registerPendingSuggestionApplier(
42
+ formId: string | undefined,
43
+ fieldName: string,
44
+ apply: PendingSuggestionApplier,
45
+ ): () => void {
46
+ const key = keyFor(formId, fieldName)
47
+ const entry: RegistryEntry = { formId, fieldName, apply }
48
+ _entries.set(key, entry)
49
+ return () => {
50
+ // Only delete if this entry is still the one we registered — a
51
+ // re-register from another instance may have replaced us.
52
+ if (_entries.get(key) === entry) _entries.delete(key)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Look up an applier for `(formId, fieldName)`. Tries the form-scoped
58
+ * key first; falls back to the wildcard form ('*') so a producer that
59
+ * pushed a suggestion without `formId` still resolves an applier from
60
+ * a single-form page.
61
+ */
62
+ export function getPendingSuggestionApplier(
63
+ formId: string | undefined,
64
+ fieldName: string,
65
+ ): PendingSuggestionApplier | undefined {
66
+ if (formId !== undefined) {
67
+ const scoped = _entries.get(keyFor(formId, fieldName))
68
+ if (scoped) return scoped.apply
69
+ }
70
+ const wild = _entries.get(keyFor(undefined, fieldName))
71
+ return wild?.apply
72
+ }
73
+
74
+ /**
75
+ * Test seam — clear the registry between tests. Not part of the public
76
+ * API.
77
+ */
78
+ export function _clearAppliersForTests(): void {
79
+ _entries.clear()
80
+ }