@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,274 @@
1
+ import { z } from "zod";
2
+ import { PrincipalSchema } from "../contracts/shared";
3
+ import { AiModelPricingSchema } from "./ai-costs";
4
+ import { AiExtraBodySchema, AiReasoningEffortSchema } from "./ai-request-options";
5
+
6
+ export const AI_MODEL_CLASSES = ["fast", "balanced", "thorough", "maximum"] as const;
7
+ export type AiModelClass = (typeof AI_MODEL_CLASSES)[number];
8
+ export const AI_MODEL_SLOTS = [...AI_MODEL_CLASSES, "vision", "audio"] as const;
9
+ export type AiModelSlot = (typeof AI_MODEL_SLOTS)[number];
10
+ export const AiModelRequestSchema = z.union([
11
+ z.object({ class: z.enum(AI_MODEL_CLASSES) }).strict(),
12
+ z.object({ id: z.string().trim().min(1).max(241) }).strict(),
13
+ ]);
14
+ export type AiModelRequest = z.infer<typeof AiModelRequestSchema>;
15
+ export const AiResolvedModelRefSchema = z
16
+ .object({
17
+ requestedClass: z.enum(AI_MODEL_CLASSES).optional(),
18
+ class: z.enum(AI_MODEL_CLASSES).optional(),
19
+ id: z.string().min(1).max(241),
20
+ })
21
+ .strict();
22
+ export type AiResolvedModelRef = { requestedClass?: AiModelClass; class?: AiModelClass; id: string };
23
+ /** Only empty assignments fall back; policy and budget failures never do. */
24
+ export const aiModelClassFallbackChain = (modelClass: AiModelClass): AiModelClass[] => {
25
+ const index = AI_MODEL_CLASSES.indexOf(modelClass);
26
+ return [...AI_MODEL_CLASSES.slice(0, index).reverse(), ...AI_MODEL_CLASSES.slice(index + 1)];
27
+ };
28
+ export const parseAiModelChoice = (value: string): AiModelRequest => {
29
+ const modelClass = z.enum(AI_MODEL_CLASSES).safeParse(value);
30
+ return modelClass.success ? { class: modelClass.data } : AiModelRequestSchema.parse({ id: value });
31
+ };
32
+
33
+ const slug = z
34
+ .string()
35
+ .trim()
36
+ .regex(/^[a-z0-9][a-z0-9._-]*$/)
37
+ .max(120);
38
+ const image = z
39
+ .string()
40
+ .trim()
41
+ .regex(/^data:image\//)
42
+ .max(28_000);
43
+ const httpUrl = z
44
+ .string()
45
+ .url()
46
+ .refine((value) => ["http:", "https:"].includes(new URL(value).protocol));
47
+ export const AiProviderTypeSchema = z.enum([
48
+ "openai",
49
+ "openrouter",
50
+ "anthropic",
51
+ "mistral",
52
+ "gemini",
53
+ "ollama",
54
+ "vllm",
55
+ "openai-compatible",
56
+ ]);
57
+ export type AiProviderType = z.infer<typeof AiProviderTypeSchema>;
58
+ /** Hosted APIs refuse requests without a key; self-hosted endpoints may run without one. */
59
+ export const providerRequiresCredential = (provider: AiProviderType): boolean =>
60
+ ["openai", "openrouter", "anthropic", "mistral", "gemini"].includes(provider);
61
+ export const providerSupportsCredential = (provider: AiProviderType): boolean =>
62
+ providerRequiresCredential(provider) || provider === "vllm" || provider === "openai-compatible";
63
+ export const AiProviderStatusSchema = z.enum(["unknown", "ok", "unreachable", "key_rejected", "error"]);
64
+ export const AiProviderInputSchema = z
65
+ .object({
66
+ id: slug,
67
+ type: AiProviderTypeSchema,
68
+ label: z.string().trim().min(1),
69
+ baseUrl: httpUrl.nullable().default(null),
70
+ dataBoundary: z.enum(["hosted", "private"]),
71
+ enabled: z.boolean().default(true),
72
+ logo: image.nullable().default(null),
73
+ })
74
+ .strict();
75
+ export const AiModelIconSchema = z.discriminatedUnion("kind", [
76
+ z.object({ kind: z.literal("provider") }).strict(),
77
+ z.object({ kind: z.literal("emoji"), emoji: z.string().min(1).max(64) }).strict(),
78
+ z.object({ kind: z.literal("image"), dataUrl: image }).strict(),
79
+ ]);
80
+ export type AiModelIcon = z.infer<typeof AiModelIconSchema>;
81
+ export const AiModelInputSchema = z
82
+ .object({
83
+ id: slug.refine((value) => !AI_MODEL_CLASSES.some((modelClass) => modelClass === value), "Model IDs cannot be class names."),
84
+ providerModel: z.string().trim().min(1),
85
+ label: z.string().trim().min(1),
86
+ description: z.string().default(""),
87
+ kind: z.enum(["chat", "transcription"]).default("chat"),
88
+ capabilities: z.array(z.enum(["tools", "vision", "transcription"])).default([]),
89
+ contextWindow: z.number().int().positive().nullable().default(null),
90
+ maxOutputTokens: z.number().int().positive().nullable().default(null),
91
+ temperature: z.number().min(0).max(2).nullable().default(null),
92
+ extraBody: AiExtraBodySchema.nullable().default(null),
93
+ maxLoadedTools: z.number().int().nullable().default(null),
94
+ maxToolRounds: z.number().int().nullable().default(null),
95
+ pricing: AiModelPricingSchema.nullable().default(null),
96
+ icon: AiModelIconSchema.default({ kind: "provider" }),
97
+ enabled: z.boolean().default(true),
98
+ })
99
+ .strict();
100
+ export const AiVariantInputSchema = z
101
+ .object({
102
+ slug,
103
+ label: z.string().trim().min(1),
104
+ hint: z.string().default(""),
105
+ reasoningEffort: AiReasoningEffortSchema.nullable().default(null),
106
+ maxOutputTokens: z.number().int().positive().nullable().default(null),
107
+ pricing: AiModelPricingSchema.nullable().default(null),
108
+ extraBody: AiExtraBodySchema.nullable().default(null),
109
+ showInPicker: z.boolean().default(true),
110
+ position: z.number().int().default(0),
111
+ })
112
+ .strict();
113
+ export const AiProviderPatchSchema = AiProviderInputSchema.omit({ id: true })
114
+ .partial()
115
+ .extend({
116
+ baseUrl: AiProviderInputSchema.shape.baseUrl.removeDefault().optional(),
117
+ enabled: AiProviderInputSchema.shape.enabled.removeDefault().optional(),
118
+ logo: AiProviderInputSchema.shape.logo.removeDefault().optional(),
119
+ })
120
+ .strict();
121
+ export const AiModelPatchSchema = AiModelInputSchema.omit({ id: true })
122
+ .partial()
123
+ .extend({
124
+ description: AiModelInputSchema.shape.description.removeDefault().optional(),
125
+ kind: AiModelInputSchema.shape.kind.removeDefault().optional(),
126
+ capabilities: AiModelInputSchema.shape.capabilities.removeDefault().optional(),
127
+ contextWindow: AiModelInputSchema.shape.contextWindow.removeDefault().optional(),
128
+ maxOutputTokens: AiModelInputSchema.shape.maxOutputTokens.removeDefault().optional(),
129
+ temperature: AiModelInputSchema.shape.temperature.removeDefault().optional(),
130
+ extraBody: AiModelInputSchema.shape.extraBody.removeDefault().optional(),
131
+ maxLoadedTools: AiModelInputSchema.shape.maxLoadedTools.removeDefault().optional(),
132
+ maxToolRounds: AiModelInputSchema.shape.maxToolRounds.removeDefault().optional(),
133
+ pricing: AiModelInputSchema.shape.pricing.removeDefault().optional(),
134
+ icon: AiModelInputSchema.shape.icon.removeDefault().optional(),
135
+ enabled: AiModelInputSchema.shape.enabled.removeDefault().optional(),
136
+ })
137
+ .strict();
138
+ export const AiVariantPatchSchema = AiVariantInputSchema.omit({ slug: true })
139
+ .partial()
140
+ .extend({
141
+ hint: AiVariantInputSchema.shape.hint.removeDefault().optional(),
142
+ reasoningEffort: AiVariantInputSchema.shape.reasoningEffort.removeDefault().optional(),
143
+ maxOutputTokens: AiVariantInputSchema.shape.maxOutputTokens.removeDefault().optional(),
144
+ pricing: AiVariantInputSchema.shape.pricing.removeDefault().optional(),
145
+ extraBody: AiVariantInputSchema.shape.extraBody.removeDefault().optional(),
146
+ showInPicker: AiVariantInputSchema.shape.showInPicker.removeDefault().optional(),
147
+ position: AiVariantInputSchema.shape.position.removeDefault().optional(),
148
+ })
149
+ .strict();
150
+ const revisionFields = {
151
+ revision: z.number().int(),
152
+ createdAt: z.string(),
153
+ updatedAt: z.string(),
154
+ updatedBy: z.string().nullable(),
155
+ };
156
+ export const AiProviderSchema = AiProviderInputSchema.extend({
157
+ ...revisionFields,
158
+ status: AiProviderStatusSchema,
159
+ statusCode: z.string().nullable(),
160
+ statusMessage: z.string().nullable(),
161
+ statusCheckedAt: z.string().nullable(),
162
+ credentialSet: z.boolean(),
163
+ requestHeaderNames: z.array(z.string()),
164
+ });
165
+ export const AiModelSchema = AiModelInputSchema.extend({ ...revisionFields, providerId: z.string() });
166
+ export const AiVariantSchema = AiVariantInputSchema.extend({ ...revisionFields, id: z.string(), modelId: z.string() });
167
+ export const AiAssignmentSchema = z.object({
168
+ slot: z.enum(AI_MODEL_SLOTS),
169
+ variantId: z.string().nullable(),
170
+ revision: z.number().int(),
171
+ updatedAt: z.string(),
172
+ updatedBy: z.string().nullable(),
173
+ });
174
+ export type AiProvider = z.infer<typeof AiProviderSchema>;
175
+ export type AiModel = z.infer<typeof AiModelSchema>;
176
+ export type AiModelVariant = z.infer<typeof AiVariantSchema>;
177
+ export type AiModelAssignment = z.infer<typeof AiAssignmentSchema>;
178
+ export type AiModelUnavailableReason =
179
+ | { code: "budget_exhausted"; resetsAt: string }
180
+ | { code: "provider_unreachable" | "key_rejected" | "access_revoked" };
181
+ export type AiPeopleModelIcon =
182
+ | { kind: "emoji"; emoji: string }
183
+ | { kind: "image"; src: string }
184
+ | { kind: "mark"; text: string; color: string };
185
+ /** Neutral provider marks: Cloud ships no brand logos, so a provider shows its initials on one of these colours. */
186
+ export const AI_PROVIDER_MARK_COLORS = ["#0f766e", "#6d28d9", "#475569", "#15803d", "#1d4ed8", "#c2410c", "#be123c", "#a16207"] as const;
187
+ /** The initials and colour a provider shows wherever it has no logo, the same in the AI area and in every chooser. */
188
+ export const aiProviderMark = (provider: { id: string; label: string }): { text: string; color: string } => {
189
+ const words = provider.label.split(/\s+/).filter(Boolean);
190
+ const capitals = words[0]?.match(/\p{Lu}/gu) ?? [];
191
+ const text =
192
+ words.length > 1
193
+ ? `${words[0]![0]}${words[1]![0]}`
194
+ : capitals.length >= 2
195
+ ? capitals.slice(0, 2).join("")
196
+ : (words[0] ?? "").slice(0, 2);
197
+ return {
198
+ text: text.toUpperCase() || "AI",
199
+ color:
200
+ AI_PROVIDER_MARK_COLORS[
201
+ Array.from(provider.id).reduce((sum, letter) => (sum * 31 + letter.charCodeAt(0)) >>> 0, 0) % AI_PROVIDER_MARK_COLORS.length
202
+ ]!,
203
+ };
204
+ };
205
+ export type AiPeopleModels = {
206
+ status: "ok";
207
+ selection: AiModelRequest | null;
208
+ effectiveDefault: AiModelRequest;
209
+ classes: { class: AiModelClass; variantId: string; unavailable?: AiModelUnavailableReason }[];
210
+ options: {
211
+ id: string;
212
+ label: string;
213
+ variantLabel?: string;
214
+ description: string;
215
+ icon: AiPeopleModelIcon;
216
+ classes: AiModelClass[];
217
+ unavailable?: AiModelUnavailableReason;
218
+ }[];
219
+ };
220
+ export const AiDeleteModelSchema = z
221
+ .object({
222
+ revision: z.number().int(),
223
+ replacements: z.partialRecord(z.enum(AI_MODEL_SLOTS), z.string().nullable()),
224
+ retarget: z.boolean(),
225
+ retargetTo: z.string().nullable(),
226
+ })
227
+ .strict();
228
+ export type AiDeleteModelInput = z.infer<typeof AiDeleteModelSchema>;
229
+ export type AiModelUsage = {
230
+ assignments: AiModelSlot[];
231
+ projects: { id: string; name: string }[];
232
+ workflows: { id: string; name: string }[];
233
+ scheduledTasks: { id: string; name: string }[];
234
+ chats: number;
235
+ budgetRules: { id: string; label: string }[];
236
+ peopleAtLimit: number;
237
+ stats: { answers14d: number; avgCostPerAnswer: number | null; avgSecondsPerAnswer: number | null };
238
+ };
239
+
240
+ const AiModelGrantSchema = z
241
+ .object({
242
+ principal: PrincipalSchema.refine((principal) => principal.type !== "public", "Assistant models cannot be public."),
243
+ permission: z.literal("read"),
244
+ })
245
+ .strict();
246
+ export const AiModelAccessDraftSchema = z
247
+ .object({
248
+ expectedRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(),
249
+ entries: z.array(AiModelGrantSchema),
250
+ })
251
+ .strict();
252
+ export type AiModelAccessDraft = z.infer<typeof AiModelAccessDraftSchema>;
253
+
254
+ /** Whether a model supports a catalog assignment; safe to use in browser pickers. */
255
+ export const aiModelSupportsSlot = (slot: AiModelSlot, model: Pick<AiModel, "kind" | "capabilities">): boolean =>
256
+ slot === "audio" ? model.kind === "transcription" : model.kind === "chat" && (slot !== "vision" || model.capabilities.includes("vision"));
257
+
258
+ /** The same replacement requirements apply to deletion previews and committed retargeting. */
259
+ export const aiReplacementProblem = (
260
+ source: Pick<AiProvider, "dataBoundary">,
261
+ target: Pick<AiProvider, "dataBoundary" | "enabled">,
262
+ original: Pick<AiModel, "kind" | "capabilities">,
263
+ replacement: Pick<AiModel, "kind" | "capabilities" | "enabled">,
264
+ ): "private_boundary" | "incompatible_model" | null => {
265
+ if (source.dataBoundary === "private" && target.dataBoundary !== "private") return "private_boundary";
266
+ if (
267
+ !target.enabled ||
268
+ !replacement.enabled ||
269
+ original.kind !== replacement.kind ||
270
+ original.capabilities.some((capability) => !replacement.capabilities.includes(capability))
271
+ )
272
+ return "incompatible_model";
273
+ return null;
274
+ };
@@ -143,7 +143,7 @@ export type AiQuotaReport = {
143
143
  perPage: number;
144
144
  };
145
145
  export const aiQuotaHref = (query: Partial<AiQuotaReportQuery>) => {
146
- const params = new URLSearchParams({ tab: "ai-quotas" });
146
+ const params = new URLSearchParams({ sec: "budgets", ...(query.view === "rules" ? {} : { rule: "people" }) });
147
147
  for (const [key, value] of Object.entries(query)) if (value !== undefined && value !== "") params.set(key, String(value));
148
- return `/admin/settings?${params}`;
148
+ return `/admin/ai?${params}`;
149
149
  };
@@ -8,7 +8,7 @@ export const AiUsageQuerySchema = z.object({
8
8
  range: z.enum(AI_USAGE_RANGES).default("30d"),
9
9
  until: z.iso.datetime({ offset: true }).optional(),
10
10
  userId: z.union([z.uuid(), z.literal("unassigned")]).optional(),
11
- modelProfileId: text,
11
+ modelId: text,
12
12
  providerModel: text,
13
13
  appId: text,
14
14
  workflowId: z.uuid().optional(),
@@ -36,4 +36,4 @@ export const aiUsageSearchParams = (query: Partial<AiUsageQuery>): URLSearchPara
36
36
  for (const [key, value] of Object.entries(query)) if (value !== undefined && value !== "") params.set(key, String(value));
37
37
  return params;
38
38
  };
39
- export const aiUsageHref = (query: Partial<AiUsageQuery>) => `/admin/settings?tab=ai-usage&${aiUsageSearchParams(query)}`;
39
+ export const aiUsageHref = (query: Partial<AiUsageQuery>) => `/admin/ai?sec=usage&${aiUsageSearchParams(query)}`;
@@ -13,6 +13,8 @@ export {
13
13
  export * from "./account-display";
14
14
  export * from "./account-session";
15
15
  export * from "./ai-costs";
16
+ export * from "./ai-models";
17
+ export * from "./ai-models";
16
18
  export type { AiPromptContextInput, AiToolPromptHint } from "./ai-platform-prompt";
17
19
  export {
18
20
  AI_LOCAL_CLIENT_DEFAULT_INSTRUCTIONS,
@@ -2,7 +2,7 @@ import type { Topic } from "@k2b/sync";
2
2
  import * as Y from "yjs";
3
3
  import { compareStreamCursor } from "./cursor";
4
4
  import { SHARED_TEXT_NAME, sharedTextNodeId, toBase64 } from "./events";
5
- import { minimalTextDiff } from "./text-diff";
5
+ import { textDiffHunks } from "./text-diff";
6
6
  import type {
7
7
  SharedTextActor,
8
8
  SharedTextContributor,
@@ -14,6 +14,8 @@ import type {
14
14
  } from "./types";
15
15
 
16
16
  type DocumentLog = {
17
+ /** Omitted logs use Sync’s default message payload limit (128 KiB). */
18
+ maxPayloadBytes?: number;
17
19
  topic(): Pick<Topic<SharedTextSyncEvent>, "latestCursor" | "publish">;
18
20
  replay(config: {
19
21
  docId: string;
@@ -48,7 +50,10 @@ export const createDoc = (stored: SharedTextStoredState): Y.Doc => {
48
50
  }
49
51
  };
50
52
 
51
- export const bindDocuments = <Saved, Context>({ topic, replay }: DocumentLog, store: SharedTextStore<Saved, Context>) => {
53
+ export const bindDocuments = <Saved, Context>(
54
+ { topic, replay, maxPayloadBytes = 128 * 1024 }: DocumentLog,
55
+ store: SharedTextStore<Saved, Context>,
56
+ ) => {
52
57
  return {
53
58
  async read(docId: string, options: { signal?: AbortSignal } = {}): Promise<SharedTextSnapshot | null> {
54
59
  const stored = await store.load(docId, options);
@@ -67,7 +72,11 @@ export const bindDocuments = <Saved, Context>({ topic, replay }: DocumentLog, st
67
72
  async edit(
68
73
  docId: string,
69
74
  options: { actor: SharedTextActor | null; context?: Context; signal?: AbortSignal },
70
- update: (current: { text: string; contributors: SharedTextContributor[]; state(): Uint8Array }) => string | Promise<string>,
75
+ update: (current: {
76
+ text: string;
77
+ contributors: SharedTextContributor[];
78
+ state(): Uint8Array;
79
+ }) => string | { update: Uint8Array } | Promise<string | { update: Uint8Array }>,
71
80
  ): Promise<SharedTextEditResult<Saved>> {
72
81
  const requestedAt = Date.now();
73
82
  const stored = await store.load(docId, { signal: options.signal });
@@ -91,12 +100,20 @@ export const bindDocuments = <Saved, Context>({ topic, replay }: DocumentLog, st
91
100
  if (next === text) return { status: "unchanged", text };
92
101
  // A request cancelled while the callback ran must not publish a write.
93
102
  options.signal?.throwIfAborted();
94
- const diff = minimalTextDiff(text, next);
95
103
  const beforeEditState = Y.encodeStateVector(doc);
96
- doc.transact(() => {
97
- if (diff.deleteCount > 0) ytext.delete(diff.index, diff.deleteCount);
98
- if (diff.insert.length > 0) ytext.insert(diff.index, diff.insert);
99
- }, "cli-edit");
104
+ if (typeof next === "string") {
105
+ const hunks = textDiffHunks(text, next, maxPayloadBytes);
106
+ doc.transact(() => {
107
+ for (const diff of hunks.reverse()) {
108
+ if (diff.deleteCount > 0) ytext.delete(diff.index, diff.deleteCount);
109
+ if (diff.insert.length > 0) ytext.insert(diff.index, diff.insert);
110
+ }
111
+ }, "cli-edit");
112
+ } else {
113
+ // Application-prepared transactions preserve exact item identities and
114
+ // may be replayed after a partial publication/persistence failure.
115
+ Y.applyUpdate(doc, next.update, "server-edit");
116
+ }
100
117
  const actor = options.actor;
101
118
  const published = await handle.publish({
102
119
  tenantId: docId,
@@ -87,7 +87,7 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
87
87
  return 0;
88
88
  },
89
89
  withStore<Saved, Context>(store: SharedTextStore<Saved, Context>) {
90
- return bindDocuments({ topic, replay }, store);
90
+ return bindDocuments({ topic, replay, maxPayloadBytes: topicConfig.maxPayloadBytes }, store);
91
91
  },
92
92
  };
93
93
  };
@@ -1,3 +1,7 @@
1
+ import { type ArrayChange, diffArrays } from "diff";
2
+
3
+ type TextDiff = { index: number; deleteCount: number; insert: string };
4
+
1
5
  const splitsSurrogatePair = (text: string, index: number): boolean => {
2
6
  const before = text.charCodeAt(index - 1);
3
7
  const after = text.charCodeAt(index);
@@ -21,3 +25,74 @@ export const minimalTextDiff = (before: string, after: string): { index: number;
21
25
 
22
26
  return { index, deleteCount: before.length - index - suffix, insert: after.slice(index, after.length - suffix) };
23
27
  };
28
+
29
+ /** Convert adjacent additions/deletions into replacements in the original text's UTF-16 offsets. */
30
+ const replacements = (parts: ArrayChange<string>[], offset: number): TextDiff[] => {
31
+ const hunks: TextDiff[] = [];
32
+ let index = offset;
33
+ let pending: TextDiff | undefined;
34
+ for (const part of parts) {
35
+ const value = part.value.join("");
36
+ if (!part.added && !part.removed) {
37
+ if (pending) hunks.push(pending);
38
+ pending = undefined;
39
+ index += value.length;
40
+ } else {
41
+ pending ??= { index, deleteCount: 0, insert: "" };
42
+ if (part.removed) {
43
+ pending.deleteCount += value.length;
44
+ index += value.length;
45
+ } else pending.insert += value;
46
+ }
47
+ }
48
+ if (pending) hunks.push(pending);
49
+ return hunks;
50
+ };
51
+
52
+ /**
53
+ * Ascending, non-overlapping hunks. Unchanged lines are matched first; only
54
+ * changed blocks are refined into Unicode code points (never half a surrogate).
55
+ * The log's payload budget bounds comparisons across both passes; comparing two
56
+ * tokens of equal length costs one more unit per 64 characters, since only those
57
+ * compare beyond their length. The square root bounds Myers's quadratic edit frontier.
58
+ * When either budget runs out, retain the old prefix/suffix replacement for
59
+ * that region: text correctness takes precedence over best-effort item identity.
60
+ */
61
+ export const textDiffHunks = (before: string, after: string, maxPayloadBytes: number): TextDiff[] => {
62
+ if (before === after) return [];
63
+ let remaining = maxPayloadBytes;
64
+ const exhausted = Symbol("diff budget exhausted");
65
+ const compare = (a: string, b: string) => {
66
+ remaining -= 1 + (a.length === b.length ? a.length >> 6 : 0);
67
+ if (remaining < 0) throw exhausted;
68
+ return a === b;
69
+ };
70
+ const diff = (a: string[], b: string[]) => {
71
+ if (remaining <= 0) return undefined;
72
+ try {
73
+ return diffArrays(a, b, { comparator: compare, maxEditLength: Math.floor(Math.sqrt(maxPayloadBytes)) });
74
+ } catch (error) {
75
+ if (error !== exhausted) throw error;
76
+ return undefined;
77
+ }
78
+ };
79
+ const lines = (text: string) => text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
80
+ // Lines both texts share at the start and end cost no budget, so a large note with small edits still diffs.
81
+ const a = lines(before);
82
+ const b = lines(after);
83
+ let start = 0;
84
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
85
+ let end = 0;
86
+ while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++;
87
+ const offset = a.slice(0, start).reduce((length, line) => length + line.length, 0);
88
+ const lineParts = diff(a.slice(start, a.length - end), b.slice(start, b.length - end));
89
+ if (!lineParts) return [minimalTextDiff(before, after)];
90
+ return replacements(lineParts, offset).flatMap((block) => {
91
+ const oldText = before.slice(block.index, block.index + block.deleteCount);
92
+ const trimmed = minimalTextDiff(oldText, block.insert);
93
+ const index = block.index + trimmed.index;
94
+ const removed = oldText.slice(trimmed.index, trimmed.index + trimmed.deleteCount);
95
+ const charParts = remaining > 0 && removed && trimmed.insert ? diff(Array.from(removed), Array.from(trimmed.insert)) : undefined;
96
+ return charParts ? replacements(charParts, index) : [{ ...trimmed, index }];
97
+ });
98
+ };
@@ -0,0 +1,131 @@
1
+ import { AppWorkspace, StatusBadge } from "@k2b/ui";
2
+ import type { JSX } from "solid-js";
3
+ import AdminLayout from "./AdminLayout";
4
+ import SettingsSaveStatus from "./SettingsSaveStatus.island";
5
+
6
+ /** One row of an area's first column. `mark` shows a dot when the section needs attention. */
7
+ export type AdminAreaItem = {
8
+ id: string;
9
+ label: string;
10
+ href: string;
11
+ icon: string;
12
+ description?: string;
13
+ mark?: "warning" | "error";
14
+ };
15
+ export type AdminAreaGroup = { title?: string; items: AdminAreaItem[] };
16
+
17
+ type AdminAreaProps = {
18
+ c: Parameters<typeof AdminLayout>[0]["c"];
19
+ /** The area's name, the head of the first column. */
20
+ label: string;
21
+ /** Accessible name of the first column's navigation, for example "Observability sections". */
22
+ sectionsLabel: string;
23
+ /** The area's start; the first column's head links there. */
24
+ href: string;
25
+ groups: AdminAreaGroup[];
26
+ /** Id of the open section's item. */
27
+ current: string;
28
+ /** Page title, usually the open section's label. */
29
+ title: string;
30
+ /** Spoken with a mark in the first column, for example "Needs attention". */
31
+ markLabel?: string;
32
+ /** Id of the deepest open level: "section", or a deeper pane id the page adds. */
33
+ level?: string;
34
+ /** The section's panes, starting with one whose id is "section". */
35
+ children: JSX.Element;
36
+ };
37
+
38
+ /**
39
+ * An administration area: the admin layout, the area's sections as the first column, and the open section beside
40
+ * it as `AppWorkspace` levels. The owner builds each section's content by hand; a wide list adds `minSize`, so it
41
+ * pushes the first column aside instead of being squeezed.
42
+ */
43
+ export function AdminArea(props: AdminAreaProps) {
44
+ return (
45
+ <AdminLayout c={props.c} title={props.title} ownMain>
46
+ <AppWorkspace.Main level={props.level ?? "section"}>
47
+ <AppWorkspace.MainPane id="area" label={props.label} href={props.href} defaultSize={248} minSize={232} maxSize={320}>
48
+ <nav aria-label={props.sectionsLabel}>
49
+ {props.groups.map((group) => (
50
+ <AppWorkspace.SidebarSection title={group.title}>
51
+ {group.items.map((item) => (
52
+ <AppWorkspace.SidebarItem
53
+ href={item.href}
54
+ navigation="document"
55
+ active={props.current === item.id}
56
+ icon={item.icon}
57
+ description={item.description}
58
+ meta={
59
+ item.mark ? (
60
+ <span title={props.markLabel}>
61
+ <StatusBadge tone={item.mark} variant="dot" label="" />
62
+ <span class="sr-only">{props.markLabel}</span>
63
+ </span>
64
+ ) : undefined
65
+ }
66
+ >
67
+ {item.label}
68
+ </AppWorkspace.SidebarItem>
69
+ ))}
70
+ </AppWorkspace.SidebarSection>
71
+ ))}
72
+ </nav>
73
+ </AppWorkspace.MainPane>
74
+ {props.children}
75
+ </AppWorkspace.Main>
76
+ </AdminLayout>
77
+ );
78
+ }
79
+
80
+ /** The save status in a level head. A phone has no room there; `AdminAreaSaveStatusFoot` shows it at the bottom. */
81
+ export const AdminAreaSaveStatusHead = () => (
82
+ <div class="hidden md:block">
83
+ <SettingsSaveStatus />
84
+ </div>
85
+ );
86
+ export const AdminAreaSaveStatusFoot = () => (
87
+ <div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-4 py-3 md:hidden">
88
+ <SettingsSaveStatus />
89
+ </div>
90
+ );
91
+
92
+ /** A section that fills the width beside the first column, such as a dashboard or one form. */
93
+ export function AdminAreaPane(props: {
94
+ label: string;
95
+ description?: JSX.Element;
96
+ /** Before the title, such as a step back inside the section. */
97
+ lead?: JSX.Element;
98
+ actions?: JSX.Element;
99
+ /** A form that saves with a button: its status, Discard and Save sit in the head, on a phone at the bottom. */
100
+ saveStatus?: boolean;
101
+ /** With a detail level beside it: the address of this level without the selection. */
102
+ href?: string;
103
+ /** A wide list declares the room it needs; the first column then gives way before the list is squeezed. */
104
+ minSize?: number;
105
+ children: JSX.Element;
106
+ }) {
107
+ return (
108
+ <AppWorkspace.MainPane
109
+ id="section"
110
+ label={props.label}
111
+ description={props.description}
112
+ lead={props.lead}
113
+ actions={
114
+ props.saveStatus ? (
115
+ <div class="flex items-center gap-2">
116
+ {props.actions}
117
+ <AdminAreaSaveStatusHead />
118
+ </div>
119
+ ) : (
120
+ props.actions
121
+ )
122
+ }
123
+ href={props.href}
124
+ minSize={props.minSize}
125
+ defaultSize={props.minSize}
126
+ >
127
+ <div class="app-rows px-[var(--ui-space-shell)] pb-[var(--ui-space-shell)]">{props.children}</div>
128
+ {props.saveStatus ? <AdminAreaSaveStatusFoot /> : null}
129
+ </AppWorkspace.MainPane>
130
+ );
131
+ }
@@ -28,6 +28,10 @@ type Props = {
28
28
  title: string;
29
29
  /** Disable when a bounded child such as SettingsPage owns scrolling. */
30
30
  scroll?: boolean;
31
+ /** Page-owned insets when its layout needs more room than the default shell spacing. */
32
+ contentClass?: string;
33
+ /** The page renders its own `AppWorkspace.Main`, such as one in level mode. */
34
+ ownMain?: boolean;
31
35
  };
32
36
  export default function AdminLayout(props: Props) {
33
37
  // Keep children lazy so Layout installs the request locale before rendering them.
@@ -46,9 +50,13 @@ export default function AdminLayout(props: Props) {
46
50
  <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1" resizable={false}>
47
51
  <AdminSidebar currentPath={currentPath} apps={runtime.apps} />
48
52
  <AppWorkspace.Content>
49
- <AppWorkspace.Main scroll={scroll} class={scroll ? "p-[var(--ui-space-shell)]" : undefined}>
50
- {props.children}
51
- </AppWorkspace.Main>
53
+ {props.ownMain ? (
54
+ props.children
55
+ ) : (
56
+ <AppWorkspace.Main scroll={scroll} class={props.contentClass ?? (scroll ? "p-[var(--ui-space-shell)]" : undefined)}>
57
+ {props.children}
58
+ </AppWorkspace.Main>
59
+ )}
52
60
  </AppWorkspace.Content>
53
61
  </AppWorkspace>
54
62
  </Layout>
@@ -1,15 +1,15 @@
1
1
  import { AppWorkspace, useLocale } from "@k2b/ui";
2
- import { activeAdminHref } from "./admin-active-link";
2
+ import { activeAdminLink } from "./admin-active-link";
3
3
  import { type AdminLink, buildAdminGroups } from "./admin-navigation";
4
4
  import { platformMessages } from "./platform-messages";
5
5
  import type { RuntimeContext } from "./runtime";
6
6
  import WorkspaceNavigation from "./WorkspaceNavigation.island";
7
7
 
8
8
  const AdminNavigation = (props: { currentPath: string; groups: ReturnType<typeof buildAdminGroups> }) => {
9
- const activeHref = activeAdminHref(
9
+ const activeHref = activeAdminLink(
10
10
  props.currentPath,
11
- props.groups.flatMap((group) => group.links.map((link: AdminLink) => link.href)),
12
- );
11
+ props.groups.flatMap((group) => group.links),
12
+ )?.href;
13
13
  return (
14
14
  <>
15
15
  {props.groups.map((group) => (
@@ -45,10 +45,10 @@ export default function AdminSidebar({ currentPath, apps }: { currentPath: strin
45
45
  icon: `ti ${link.icon}`,
46
46
  active:
47
47
  link.href ===
48
- activeAdminHref(
48
+ activeAdminLink(
49
49
  currentPath,
50
- groups().flatMap((entry) => entry.links.map((entry) => entry.href)),
51
- ),
50
+ groups().flatMap((entry) => entry.links),
51
+ )?.href,
52
52
  })),
53
53
  }))}
54
54
  />