From 9f334010047c4b4d9617fdbdd14e683cfc9bcca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:50:06 +0000 Subject: [PATCH] =?UTF-8?q?docs(ui):=20=E7=BB=99=20apps.mdx=20=E8=A1=A5=20?= =?UTF-8?q?contextSelectors=20/=20defaultAgent=20=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E4=BD=9C=E8=80=85=E9=9D=A2=E9=94=AE=20(#5891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/ui/apps.mdx` 的 App Properties 表读起来像 App 作者面的全集, 但 `APP_KEYS` 里的 `contextSelectors` 与 `defaultAgent` 两键既不在表里, 也在整个手写文档树零覆盖(此前只存在于生成的 content/docs/references/)。 对作者而言这不是"少写一行",而是把键变成不可发现。 - App Properties 表补两行,各自链到自己的小节; - 新增 `## Context Selectors`:模板变量注入语义(值以 `id` 命名注入, 与 `{current_user_id}` 同一套替换)、`optionsSource` 子表、 "强制作用域、无 All 行、首项自动选中"、以及 17.0.0 退休的 `includeAll` / `placement` 两键;例子取平台自身 Studio 的 package scope, 带 `os:check` 标记,由 check:skill-examples 对着真实 spec 编译; - 新增 `## Default Agent`:可解析值只有 `ask` / `build` 两个平台 agent (ADR-0063 撤回租户自定义 agent,表外的名字解析不到,回落平台默认)。 `hidden` 按分诊裁定不在本单范围(语义正由 #4829 争议中)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- content/docs/ui/apps.mdx | 167 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index bba17f06f8..9e497de1f9 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -49,8 +49,10 @@ const crmApp = defineApp({ | `isDefault` | `boolean` | optional | Is default app | | `navigation` | `NavigationItem[]` | optional | Navigation tree | | `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) | +| `contextSelectors` | `AppContextSelector[]` | optional | Sidebar scope dropdowns whose value is injected into navigation items — see [Context Selectors](#context-selectors) | | `branding` | `AppBranding` | optional | Visual customization | | `requiredPermissions` | `string[]` | optional | Required permissions to access | +| `defaultAgent` | `string` | optional | Platform agent bound to this app's ambient AI chat — see [Default Agent](#default-agent) | **Older app samples no longer parse** — check yours before copying it forward. @@ -410,6 +412,170 @@ const fieldServiceApp = defineApp({ }); ``` +## Context Selectors + +A **context selector** is an app-level *scope* dropdown — a Package filter, an +Environment switcher, a Locale picker — rendered at the top of the sidebar, +above the navigation tree. Its current value is published under the selector's +own `id` and substituted into navigation items as `{}`, so picking an option +re-scopes every item below it without the value being wired into each item by +hand. + +The substitution is the one already used by `{current_user_id}` / +`{current_org_id}` (see [Object Navigation](#object-navigation)). The active +value is injected into: + +- an object item's `recordId`, and each value in its `filters` map; +- a `page` or `component` item's string `params` values. + +A variable with no active value resolves to nothing, and the entry it appears in +is dropped from the resolved URL rather than emitted empty — an unscoped shell +still produces well-formed links. + +### Selector properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `id` | `string` | ✅ | Selector id (`snake_case`) — **also the template-variable name**: `id: 'active_package'` is referenced as `{active_package}` | +| `label` | `string` | ✅ | Dropdown label | +| `icon` | `string` | optional | Icon name (Lucide) | +| `optionsSource` | `object` | ✅ | Where the dropdown's options come from — see below | +| `allValue` | `string` | optional | Sentinel meaning "nothing concrete is selected yet" (default: `''`) | +| `persist` | `'query' \| 'session' \| 'none'` | optional | How the selection survives navigation (default: `'query'`) | + +`optionsSource` re-uses an existing REST surface instead of requiring a bespoke +option API: the shell fetches `endpoint` and maps each returned row to one +option. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `endpoint` | `string` | ✅ | REST endpoint returning the option rows (e.g. `/api/v1/packages`) | +| `valueKey` | `string` | optional | Row property used as the option value; dotted paths allowed (default: `id`) | +| `labelKey` | `string` | optional | Row property used as the option label; dotted paths allowed (default: `name`) | +| `filter` | `{ key, op, value }[]` | optional | Predicates (AND) a row must satisfy before it becomes an option | + +Each `filter` entry compares the dotted path `key` against `value` using `op` — +`eq` (default), `ne`, `in` or `nin`. That is what keeps a shared endpoint +generic while an individual selector narrows what it offers. + +### A selector is a mandatory scope + +There is no "All" row, and no key asks for one. A selector exists to scope a +surface, so an "All" choice would clear the very thing it declares — on Studio's +package scope that would mean listing the platform's own `system` / `cloud` +kernel metadata to a developer who scoped to their own package. The shell +instead **auto-selects the first option** as soon as the list resolves and +nothing concrete is selected yet. + +That is what `allValue` names: the value the scope variable holds *before* a +concrete pick — not an "All" option. Empty string is almost always right; set it +only if a real option value would collide with `''`. To widen what a selector +offers, widen `optionsSource.filter`. + + + **One scope per app today.** `contextSelectors` is an array, but the shipped + shell tracks a single active scope: the selection is reflected onto the URL + under one fixed query key that *every* declared selector reads back, so a + second selector mirrors the first instead of scoping independently. Declare + one selector per app. + + +### Retired selector keys + + + `contextSelectors[].includeAll` and `contextSelectors[].placement` were removed + in `@objectstack/spec` 17.0.0 (#4509, ADR-0049 enforce-or-remove) and are now + **refused at parse time** rather than ignored, so one leftover key fails the + whole save. Run `os migrate meta --from 16` to rewrite existing sources + automatically. + + - `includeAll` — not merely unread, deliberately **disobeyed**, for the reason + above: the renderer never offered an "All" row regardless of the flag, so + `includeAll: false` hardened nothing and `includeAll: true` unlocked nothing. + Delete the key; widen `optionsSource.filter` to widen the choices. + - `placement` — no renderer ever read it. Selectors always render in the + sidebar header block, and `'topbar'` placed nothing in the topbar. Delete + the key. + + Both carried schema **defaults**, which is why removal was the only channel + that could reach an author: a default materialises at parse time, so no lint + could tell an authored value from one the schema supplied. + + +### Example — a package scope + +The platform's own Studio app is the shipped example: one selector scopes every +metadata surface in its sidebar to the selected package. + +{/* os:check */} +```typescript +import { defineApp } from '@objectstack/spec'; + +const studioApp = defineApp({ + name: 'studio', + label: 'Studio', + icon: 'hammer', + + contextSelectors: [ + { + // Referenced below as `{active_package}`. + id: 'active_package', + label: 'Package', + icon: 'package', + optionsSource: { + endpoint: '/api/v1/packages', + valueKey: 'manifest.id', + labelKey: 'manifest.name', + // Keep the platform's own kernel packages out of a developer-facing + // scope: only project-scoped packages are selectable. + filter: [{ key: 'manifest.scope', op: 'nin', value: ['system', 'cloud'] }], + }, + allValue: '', + persist: 'query', + }, + ], + + navigation: [ + // One entry, scoped to whichever package the dropdown has active. + { + id: 'nav_objects', + type: 'component', + label: 'Objects', + icon: 'database', + componentRef: 'metadata:resource', + params: { type: 'object', package: '{active_package}' }, + }, + ], + + requiredPermissions: ['studio.access'], +}); +``` + +## Default Agent + +`defaultAgent` binds this app's **ambient AI chat** — the assistant the shell +opens inside the app — to one platform agent, so the user never picks from a +roster. It is a surface-binding knob, not a custom-agent slot: + +- **Omit it** on a data app. `ask` is the implicit default for every app that + does not pin one, which is what an ordinary data surface wants. +- **Set `'build'`** on an *authoring* surface (Studio is the built-in example), + so the app opens the metadata-authoring assistant instead. + +Those two platform agents are the whole resolvable set. Tenant and app-package +custom agents were withdrawn in ADR-0063, so a name outside it — `sales_copilot`, +say — parses and then binds nothing: the chat surface falls back to the platform +default at resolution time. Give an app deeper AI capability by authoring +**skills**, which attach to the platform agents by surface affinity — see +[AI Agents](/docs/ai/agents). + + + The in-product chat runtime this key binds ships in **ObjectOS**, not in the + open-source framework, which reaches your metadata over MCP (BYO-AI) instead. + The key is authorable either way — in the open edition there is simply no + in-product chat surface for it to bind. + + ## Branding Customize the visual appearance of the app: @@ -500,3 +666,4 @@ const projectApp = defineApp({ - [Dashboard Metadata](/docs/ui/dashboards) — Dashboards referenced from navigation - [View Metadata](/docs/ui/views) — Views displayed within navigation items - [Permission Metadata](/docs/permissions/permission-metadata) — Access control for apps +- [AI Agents](/docs/ai/agents) — The two platform agents `defaultAgent` binds