Skip to content

Commit 3d5c090

Browse files
os-zhuangzhuangjianguoclaude
authored
feat(spec,cli): description is authorable on an action (#7367) (#7430)
The renderer half already shipped and was unreachable. objectui's ActionParamDialog renders an action description as the dialog's DialogDescription, two handlers feed it via actionDescription(...), and useObjectLabel already resolves objects.{o}._actions.{a}.description with a globalActions.{a}.description fallback — but ActionSchema (strictObject) refused the key and the translation shape refused the bundle slot, so no producer could reach any of it. Three surfaces move together so the key is never declared without being extractable: - ActionSchema: optional `description`, I18nLabel-shaped exactly as `label`. - actionTranslationSchema: the matching slot at both addresses. - os i18n extract: emits it beside label/confirmText/successMessage/params, seeded only when declared (pushOptional, not pushDerived — the dialog has its own generic fallback, so an undeclared description is not a gap). Mechanism for the maintainer's 2026-08-10 ruling on #7278: an action with params carries its confirm question here instead of in confirmText, so one decision is one dialog. InlineActionSchema deliberately does NOT gain the key — that shape forwards only what a host renderer honours. Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fe2dfa1 commit 3d5c090

12 files changed

Lines changed: 538 additions & 6 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(spec,cli): `description` is authorable on an action (#7367)
7+
8+
An action may now declare a top-level `description`, I18nLabel-shaped exactly as
9+
`label` is (plain string or `{ en, 'zh-CN', … }` map). It is the explanatory line
10+
the param dialog shows under the title.
11+
12+
**This closes a producer gap, not a renderer gap.** The consumer half already
13+
shipped and has been unreachable: objectui's `ActionParamDialog` renders the
14+
string as the dialog's `DialogDescription`, two independent handlers feed it as
15+
`actionDescription(objectName, actionName, action.description)`, and the
16+
resolver already walks `objects.{object}._actions.{action}.description` with a
17+
`globalActions.{action}.description` fallback. Nothing could author any of it —
18+
`ActionSchema` is a `strictObject` and refused the key outright, and the
19+
translation shape refused the matching bundle key. The mirror image of
20+
declared-but-unenforced: machinery with no way in.
21+
22+
Three surfaces move together, so the key is never declared without being
23+
extractable:
24+
25+
- **`ActionSchema`** — optional `description`.
26+
- **Action translations** (`objects.{o}._actions.{a}` and `globalActions.{a}`) —
27+
the matching `description` slot, so a bundle can carry the translated string
28+
at the address the resolver already reads.
29+
- **`os i18n extract`** — emits the key beside `label` / `confirmText` /
30+
`successMessage` / `params`. It is seeded only when the action declares one;
31+
an action without a description is not a translation gap, because the dialog
32+
falls back to its own generic string.
33+
34+
**What to write in it.** An action that collects `params` and also sets
35+
`confirmText` shows two dialogs for one decision — the confirm, then the param
36+
prompt. Per the maintainer's 2026-08-10 ruling, carry the confirm question in
37+
`description` instead: one condition, one wording, one dialog, nothing sent until
38+
that dialog's own Confirm. `confirmText` remains correct for a param-less action,
39+
where the confirm is the only dialog.
40+
41+
`description` is not `ai.description`. That one is the LLM-facing tool contract
42+
(≥40 chars, required when `ai.exposed`) and is unchanged; this one is
43+
human-facing dialog copy and is never sent to a model.
44+
45+
Additive and optional: every existing action, bundle and extract keeps parsing
46+
unchanged. Inline actions (`InlineActionSchema`) deliberately do not gain the
47+
key — that shape forwards only what a host renderer honours, and widens when a
48+
renderer widens.

content/docs/references/data/object.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const result = ApiMethod.parse(data);
142142
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
143143
| **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be &lt;= sharingModel in openness. |
144144
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
145-
| **actions** | `{ name: string; label: string \| Record<string, string>; objectName?: string; icon?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
145+
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
146146
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. |
147147
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
148148
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

content/docs/references/kernel/metadata-plugin.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ const result = MetadataBulkResultSchema.parse(data);
209209
| **executionPinned** | `boolean` | optional | Transaction rows reference a specific version_hash; history GC is disabled and getByHash() MUST resolve old hashes (ADR-0009) |
210210
| **loadOrder** | `integer` | optional | Loading priority (lower = earlier) |
211211
| **domain** | `Enum<'data' \| 'ui' \| 'automation' \| 'system' \| 'security' \| 'ai'>` || Protocol domain |
212-
| **actions** | `{ name: string; label: string \| Record<string, string>; objectName?: string; icon?: string; … }[]` | optional | Declarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted |
212+
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Declarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted |
213213

214214
### Allowed Values: `MetadataTypeRegistryEntry.type`
215215

content/docs/references/system/translation.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Translation data for a single object
8585
| **description** | `string` | optional | Translated object description |
8686
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
8787
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
88-
| **_actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, object>; … }>` | optional | Action translations keyed by action name |
88+
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
8989
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
9090
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
9191

@@ -143,7 +143,7 @@ Translation data for objects, apps, and UI messages
143143
| **objects** | `Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, object>; … }>` | optional | Object translations keyed by object name |
144144
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
145145
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
146-
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, object>; … }>` | optional | Global action translations keyed by action name |
146+
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
147147
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
148148
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
149149
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
@@ -196,7 +196,7 @@ One locale of translations — the `translation` metadata type
196196
| **objects** | `Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, object>; … }>` | optional | Object translations keyed by object name |
197197
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
198198
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
199-
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, object>; … }>` | optional | Global action translations keyed by action name |
199+
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
200200
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
201201
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
202202
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |

content/docs/references/ui/action.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const result = ActionSchema.parse(data);
6262
| :--- | :--- | :--- | :--- |
6363
| **name** | `string` || Machine name (lowercase snake_case) |
6464
| **label** | `string \| Record<string, string>` || Display label |
65+
| **description** | `string \| Record<string, string>` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two — #7278). Not the LLM-facing `ai.description`. |
6566
| **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). |
6667
| **icon** | `string` | optional | Icon name |
6768
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible |

packages/cli/src/utils/i18n-extract.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,14 @@
3535
* objects.<name>._views.<view>.description
3636
* objects.<name>._views.<view>.emptyState.title / .message
3737
* objects.<name>._actions.<action>.label
38+
* objects.<name>._actions.<action>.description
3839
* objects.<name>._actions.<action>.confirmText
3940
* objects.<name>._actions.<action>.successMessage
4041
* objects.<name>._actions.<action>.params.<param>.label / .helpText / .placeholder
4142
* objects.<name>._actions.<action>.params.<param>.options.<value>
4243
* objects.<name>._actions.<action>.resultDialog.title / .description / .acknowledge
4344
* objects.<name>._actions.<action>.resultDialog.fields.<path>
44-
* globalActions.<action>.label / .confirmText / .successMessage
45+
* globalActions.<action>.label / .description / .confirmText / .successMessage
4546
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
4647
* apps.<app>.label / .description
4748
* apps.<app>.navigation.<id>.label
@@ -687,6 +688,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
687688
const aname = action.name as string;
688689
const aroot = ['objects', objectName, '_actions', aname];
689690
pushDerived(out, [...aroot, 'label'], action.label ?? aname, inlineText(action.label), 'action', { objectName });
691+
pushOptional(out, [...aroot, 'description'], action.description, 'action', { objectName });
690692
pushOptional(out, [...aroot, 'confirmText'], action.confirmText, 'action', { objectName });
691693
pushOptional(out, [...aroot, 'successMessage'], action.successMessage, 'action', { objectName });
692694
pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
@@ -760,6 +762,12 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
760762
: ['globalActions', action.name];
761763
const kind: ExpectedEntry['source'] = objectName ? 'action' : 'globalAction';
762764
pushDerived(out, [...root, 'label'], action.label ?? action.name, inlineText(action.label), kind, { objectName });
765+
// `description` is OPTIONAL-not-derived, exactly like confirmText: the
766+
// param dialog falls back to its own generic `actionDialog.description`
767+
// string when the action declares none, so an undeclared description is
768+
// not an i18n gap to seed (`pushDerived` would invent an English source
769+
// string nothing authored). #7367.
770+
pushOptional(out, [...root, 'description'], action.description, kind, { objectName });
763771
pushOptional(out, [...root, 'confirmText'], action.confirmText, kind, { objectName });
764772
pushOptional(out, [...root, 'successMessage'], action.successMessage, kind, { objectName });
765773
pushActionParams(out, root, action, kind, objectName);

0 commit comments

Comments
 (0)