@proteos/component-sdk 0.61.0 → 0.75.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.
@@ -1,3 +1,5 @@
1
+ import { PageAction } from '@proteos/sdk';
2
+
1
3
  declare const MESSAGE_SOURCE: "proteos-component-runtime";
2
4
  /**
3
5
  * z-index the component iframe is promoted to while one of its own overlays
@@ -28,6 +30,34 @@ interface OverlayRect {
28
30
  width: number;
29
31
  height: number;
30
32
  }
33
+ /** What the viewer is doing with the record on the host page.
34
+ *
35
+ * - `view` — a read-only record page, or a page without a record
36
+ * (platform, kiosk, public, previews).
37
+ * - `create` — the form for a record that is not saved yet (`record` holds
38
+ * the seed values only, no `id`).
39
+ * - `edit` — the form over an existing record (`record` is the SAVED row;
40
+ * in-flight edits are not reflected).
41
+ *
42
+ * Inside an action's / workflow's invoke dialog (a trigger page) `mode` is
43
+ * `create` while the person fills the params and `view` once the run is on
44
+ * its way; `phase` says where the run is.
45
+ *
46
+ * A snapshot like `record`: the host remounts the frame when the page
47
+ * switches between view and edit, so a new INIT carries the new mode. Also
48
+ * the Liquid scope key `mode` when props are resolved. */
49
+ type PageMode = 'view' | 'create' | 'edit';
50
+ /** Where an invocation is in the invoke dialog's lifecycle — set only on a
51
+ * trigger page (the dialog of an action / workflow); absent everywhere else.
52
+ *
53
+ * - `input` — the form over the params (`record` = the seeded params).
54
+ * - `running` — the run is in flight (`record` = the submitted params;
55
+ * `params.execution` for a workflow).
56
+ * - `result` — the run ended (`record` = the params plus the action's
57
+ * returns; `params.result` / `params.execution`).
58
+ *
59
+ * Also the Liquid scope key `phase`. */
60
+ type TriggerPhase = 'input' | 'running' | 'result';
31
61
  interface PageContext {
32
62
  record?: Record<string, unknown>;
33
63
  entity?: {
@@ -35,6 +65,8 @@ interface PageContext {
35
65
  [key: string]: unknown;
36
66
  };
37
67
  params?: Record<string, unknown>;
68
+ mode: PageMode;
69
+ phase?: TriggerPhase;
38
70
  /** The signed-in viewer (absent on public pages and offline previews). The
39
71
  * host resolves `{{ current_user.* }}` and the bare `current_user` prop
40
72
  * sentinel against it before posting props. */
@@ -55,6 +87,21 @@ interface InitMessage {
55
87
  authToken: string;
56
88
  /** API origin the bundle endpoint + SDK calls resolve against. */
57
89
  apiBaseUrl: string;
90
+ /** Content hash (hex SHA-256) of the component's deployed bundle, from the
91
+ * component's `bundle_checksum`. The runtime appends it as `?v=` to the
92
+ * bundle request so the server can mark the response immutable — the
93
+ * browser then serves every later mount from its cache without a round
94
+ * trip. Absent when the host does not know it yet (metadata still
95
+ * loading, or a row deployed before checksum stamping); the bundle then
96
+ * revalidates as before. */
97
+ bundleVersion?: string;
98
+ /** Key under which the runtime shares its query cache with sibling and
99
+ * successor frames of the same component through the host (same
100
+ * origin): the host warms a new frame from the last state a frame with
101
+ * this key left behind. Covers the slug AND the resolved props, so two
102
+ * placements configured differently never share. Absent on hosts that
103
+ * do not keep a store (previews, public pages). */
104
+ cacheKey?: string;
58
105
  /** Resolved props for the component (Liquid templating happens host-side). */
59
106
  props: Record<string, unknown>;
60
107
  pageCtx: PageContext;
@@ -96,7 +143,58 @@ interface ModalClosedMessage {
96
143
  requestId: string;
97
144
  result?: unknown;
98
145
  }
99
- type HostMessage = InitMessage | AuthRefreshMessage | DataInvalidateMessage | OverlayRectMessage | ModalClosedMessage;
146
+ /** How a RUN_PAGE_ACTION ended, by what the button did:
147
+ *
148
+ * - `done` — the run went through: an action's `returns` (null when it
149
+ * returned none), a workflow's accepted `executionId`, the
150
+ * record an intent created / updated.
151
+ * - `failed` — the invocation failed and the person closed the error.
152
+ * - `cancelled` — the person stepped out before anything ran or saved.
153
+ * - `handed_over` — an `agent` / `link` button: the chat or the tab is open;
154
+ * what happens there is not reported back.
155
+ * - `unavailable` — nothing was opened: `invalid` (the action does not parse),
156
+ * `hidden` (its `visible_when` does not match, an intent
157
+ * has no record to act on, a link no address to open),
158
+ * `busy` (another page action is still open), `unsupported`
159
+ * (a public page, a host without page actions — a preview —
160
+ * or an agent button where there is no agent window). */
161
+ type PageActionOutcome = {
162
+ status: 'done';
163
+ kind: 'action';
164
+ returns: Record<string, unknown> | null;
165
+ } | {
166
+ status: 'done';
167
+ kind: 'workflow';
168
+ executionId: string;
169
+ } | {
170
+ status: 'done';
171
+ kind: 'intent';
172
+ mode: 'create' | 'update';
173
+ entitySlug: string;
174
+ recordId: string;
175
+ title: string;
176
+ } | {
177
+ status: 'failed';
178
+ error: {
179
+ code: string;
180
+ message: string;
181
+ };
182
+ } | {
183
+ status: 'cancelled';
184
+ } | {
185
+ status: 'handed_over';
186
+ kind: 'agent' | 'link';
187
+ } | {
188
+ status: 'unavailable';
189
+ reason: 'invalid' | 'hidden' | 'busy' | 'unsupported';
190
+ };
191
+ /** Resolution of a RUN_PAGE_ACTION request, once the run's flow is over. */
192
+ interface PageActionSettledMessage {
193
+ type: 'PAGE_ACTION_SETTLED';
194
+ requestId: string;
195
+ outcome: PageActionOutcome;
196
+ }
197
+ type HostMessage = InitMessage | AuthRefreshMessage | DataInvalidateMessage | OverlayRectMessage | ModalClosedMessage | PageActionSettledMessage;
100
198
  interface ReadyMessage {
101
199
  type: 'READY';
102
200
  }
@@ -204,22 +302,82 @@ interface OpenAgentWindowMessage {
204
302
  sessionId?: string;
205
303
  agentKey?: string;
206
304
  prompt?: string;
305
+ /** Model-only context sent with `prompt`; never rendered. */
306
+ context?: string;
207
307
  }
208
308
  /** Ask the host to minimize its agent session window. The active session and
209
309
  * its stream stay alive — reopening resumes it. */
210
310
  interface CloseAgentWindowMessage {
211
311
  type: 'CLOSE_AGENT_WINDOW';
212
312
  }
313
+ /** A record write the component made through its sdk (`data.records.*`)
314
+ * succeeded. Posted by the runtime shell — not by component code — so the
315
+ * host can refresh what it shows of that entity and, when the component
316
+ * sits in a surface that reports edits (the Ava workspace), tell the agent.
317
+ * `patch` is what was submitted, `record` what the server returned. */
318
+ interface RecordWrittenMessage {
319
+ type: 'RECORD_WRITTEN';
320
+ entitySlug: string;
321
+ operation: 'create' | 'update' | 'delete' | 'batch_upsert';
322
+ recordId?: string;
323
+ patch?: Record<string, unknown>;
324
+ record?: Record<string, unknown>;
325
+ /** Rows in a batch upsert. */
326
+ count?: number;
327
+ }
328
+ /** A record a page action acts on, as the component names it. */
329
+ interface PageActionRecord {
330
+ entitySlug: string;
331
+ recordId: string;
332
+ /** Shown when the person picks among several records for an intent. */
333
+ title?: string;
334
+ /** The entity's display name, shown beside `title` in that pick. */
335
+ entityName?: string;
336
+ }
337
+ /** What the component knows that the button needs — layered over what the
338
+ * host already knows about the page. */
339
+ interface PageActionContext {
340
+ /** Merged over the page scope (`record`, `entity`, `params`, `mode`,
341
+ * `current_user`) the action's Liquid templates render against — `params`
342
+ * / `inputs` prefill, `prompt`, `context`, `entity`, `record`, `url`.
343
+ * Shallow: a key given here replaces the page's. `current_user` is always
344
+ * the host's. */
345
+ scope?: Record<string, unknown>;
346
+ /** The records the button acts on. Default: the page record (none on a page
347
+ * without one).
348
+ *
349
+ * - `action` with entity scope — the one record it is invoked on.
350
+ * - `action` with entity_batch scope — every record, in ONE invocation.
351
+ * - `intent` `update_record` without a `record` — the records of the
352
+ * intent's entity it may update: one is updated, several are offered
353
+ * to pick from. */
354
+ records?: PageActionRecord[];
355
+ }
356
+ /** Ask the host to run a page action — the same button a page or list toolbar
357
+ * carries, defined inline by the component. The host dispatches it exactly
358
+ * as a toolbar press (invoke dialog, record proposal review, agent window,
359
+ * new tab) and answers PAGE_ACTION_SETTLED with the same `requestId` once
360
+ * the flow is over.
361
+ *
362
+ * One page action at a time: while one is open (from any component) the
363
+ * host answers further requests `unavailable: busy`. */
364
+ interface RunPageActionMessage {
365
+ type: 'RUN_PAGE_ACTION';
366
+ /** Correlates the eventual PAGE_ACTION_SETTLED. Generated by the runtime. */
367
+ requestId: string;
368
+ action: PageAction;
369
+ context?: PageActionContext;
370
+ }
213
371
  interface ErrorMessage {
214
372
  type: 'ERROR';
215
373
  message: string;
216
374
  stack?: string;
217
375
  }
218
- type RuntimeMessage = ReadyMessage | ResizeMessage | NavigateMessage | OpenPeekMessage | OpenCallMessage | OverlayStateMessage | OpenModalMessage | ModalResultMessage | EscapePressedMessage | OpenAgentWindowMessage | CloseAgentWindowMessage | ErrorMessage;
376
+ type RuntimeMessage = ReadyMessage | ResizeMessage | NavigateMessage | OpenPeekMessage | OpenCallMessage | OverlayStateMessage | OpenModalMessage | ModalResultMessage | EscapePressedMessage | OpenAgentWindowMessage | CloseAgentWindowMessage | RecordWrittenMessage | RunPageActionMessage | ErrorMessage;
219
377
  type Envelope<T> = T & {
220
378
  source: typeof MESSAGE_SOURCE;
221
379
  };
222
380
  declare function isHostMessage(data: unknown): data is Envelope<HostMessage>;
223
381
  declare function isRuntimeMessage(data: unknown): data is Envelope<RuntimeMessage>;
224
382
 
225
- export { type AuthRefreshMessage, COMPONENT_OVERLAY_Z_INDEX, type CloseAgentWindowMessage, type DataInvalidateMessage, type Envelope, type ErrorMessage, type EscapePressedMessage, type HostMessage, type InitMessage, MESSAGE_SOURCE, type ModalClosedMessage, type ModalResultMessage, type ModalWidth, type NavigateMessage, type OpenAgentWindowMessage, type OpenCallMessage, type OpenModalMessage, type OpenPeekMessage, type OverlayRect, type OverlayRectMessage, type OverlayStateMessage, type PageContext, type ReadyMessage, type ResizeMessage, type RuntimeMessage, isHostMessage, isRuntimeMessage };
383
+ export { type AuthRefreshMessage, COMPONENT_OVERLAY_Z_INDEX, type CloseAgentWindowMessage, type DataInvalidateMessage, type Envelope, type ErrorMessage, type EscapePressedMessage, type HostMessage, type InitMessage, MESSAGE_SOURCE, type ModalClosedMessage, type ModalResultMessage, type ModalWidth, type NavigateMessage, type OpenAgentWindowMessage, type OpenCallMessage, type OpenModalMessage, type OpenPeekMessage, type OverlayRect, type OverlayRectMessage, type OverlayStateMessage, type PageActionContext, type PageActionOutcome, type PageActionRecord, type PageActionSettledMessage, type PageContext, type PageMode, type ReadyMessage, type RecordWrittenMessage, type ResizeMessage, type RunPageActionMessage, type RuntimeMessage, type TriggerPhase, isHostMessage, isRuntimeMessage };
package/dist/protocol.js CHANGED
@@ -1,3 +1,3 @@
1
- export { COMPONENT_OVERLAY_Z_INDEX, MESSAGE_SOURCE, isHostMessage, isRuntimeMessage } from './chunk-NOCSMXCW.js';
1
+ export { COMPONENT_OVERLAY_Z_INDEX, MESSAGE_SOURCE, isHostMessage, isRuntimeMessage } from './chunk-IYXDPNMJ.js';
2
2
  //# sourceMappingURL=protocol.js.map
3
3
  //# sourceMappingURL=protocol.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/component-sdk",
3
- "version": "0.61.0",
3
+ "version": "0.75.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Typed runtime hooks for authoring Proteos custom components — the SDK you import inside a component bundle.",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "src"
43
43
  ],
44
44
  "dependencies": {
45
- "@proteos/sdk": "^0.61.0"
45
+ "@proteos/sdk": "^0.75.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@tanstack/react-query": ">=5.0.0",
package/src/context.tsx CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  FunctionsClient,
7
7
  KnowledgeClient,
8
8
  MetaClient,
9
+ PageAction,
9
10
  StorageClient,
10
11
  } from '@proteos/sdk'
11
12
  import { createContext, type ReactNode, useMemo } from 'react'
@@ -22,7 +23,10 @@ import {
22
23
  type OpenCallMessage,
23
24
  type OpenModalMessage,
24
25
  type OpenPeekMessage,
26
+ type PageActionContext,
27
+ type PageActionOutcome,
25
28
  type PageContext,
29
+ type RunPageActionMessage,
26
30
  } from './protocol'
27
31
 
28
32
  /** Options for {@link RuntimeContextValue.navigate}. */
@@ -58,6 +62,10 @@ export interface OpenAgentWindowOptions {
58
62
  /** A message to send — into the resumed session, or as the first message
59
63
  * of the fresh one. */
60
64
  prompt?: string
65
+ /** Context handed to the model alongside `prompt` (what the component knows:
66
+ * the record on screen, a selection …). Read by the model, never shown in
67
+ * the chat; sent only together with a `prompt`. Not for secrets. */
68
+ context?: string
61
69
  }
62
70
 
63
71
  /**
@@ -158,6 +166,9 @@ export interface RuntimeContextValue extends RuntimeValue {
158
166
  /** Open / minimize the host's agent session window (posts OPEN_AGENT_WINDOW
159
167
  * / CLOSE_AGENT_WINDOW). */
160
168
  agentWindow: AgentWindowController
169
+ /** Run a page action defined inline (posts RUN_PAGE_ACTION); resolves with
170
+ * how its flow ended. */
171
+ runPageAction: (action: PageAction, context?: PageActionContext) => Promise<PageActionOutcome>
161
172
  }
162
173
 
163
174
  export const RuntimeContext = createContext<RuntimeContextValue | null>(null)
@@ -194,6 +205,7 @@ function openAgentWindow(options?: OpenAgentWindowOptions): void {
194
205
  ...(options?.sessionId !== undefined ? { sessionId: options.sessionId } : {}),
195
206
  ...(options?.agentKey !== undefined ? { agentKey: options.agentKey } : {}),
196
207
  ...(options?.prompt !== undefined ? { prompt: options.prompt } : {}),
208
+ ...(options?.context !== undefined ? { context: options.context } : {}),
197
209
  }
198
210
  window.parent?.postMessage(envelope, '*')
199
211
  }
@@ -215,24 +227,35 @@ function navigate(to: string, options?: NavigateOptions): void {
215
227
  else postNavigate(to)
216
228
  }
217
229
 
218
- // ── Component modals ────────────────────────────────────────────────────────
219
- // One module-level listener + a pending map, rather than a listener per call:
220
- // the SDK is an import-mapped singleton, so this is per-iframe state.
230
+ // ── Host requests (component modals, page actions) ──────────────────────────
231
+ // One module-level listener + a pending map per request kind, rather than a
232
+ // listener per call: the SDK is an import-mapped singleton, so this is
233
+ // per-iframe state.
221
234
 
222
235
  const pendingModals = new Map<string, (result: unknown) => void>()
223
- let modalListenerAttached = false
236
+ const pendingPageActions = new Map<string, (outcome: PageActionOutcome) => void>()
237
+ let replyListenerAttached = false
224
238
 
225
- function ensureModalListener(): void {
226
- if (modalListenerAttached) return
227
- modalListenerAttached = true
239
+ function ensureReplyListener(): void {
240
+ if (replyListenerAttached) return
241
+ replyListenerAttached = true
228
242
  window.addEventListener('message', (event: MessageEvent) => {
229
243
  const data: unknown = event.data
230
- if (!isHostMessage(data) || data.type !== 'MODAL_CLOSED') return
231
- const resolve = pendingModals.get(data.requestId)
232
- // An unknown requestId is someone else's modal (or a stale reply) — drop it.
233
- if (!resolve) return
234
- pendingModals.delete(data.requestId)
235
- resolve(data.result)
244
+ if (!isHostMessage(data)) return
245
+ // An unknown requestId is someone else's request (or a stale reply) — drop it.
246
+ if (data.type === 'MODAL_CLOSED') {
247
+ const resolve = pendingModals.get(data.requestId)
248
+ if (!resolve) return
249
+ pendingModals.delete(data.requestId)
250
+ resolve(data.result)
251
+ return
252
+ }
253
+ if (data.type === 'PAGE_ACTION_SETTLED') {
254
+ const resolve = pendingPageActions.get(data.requestId)
255
+ if (!resolve) return
256
+ pendingPageActions.delete(data.requestId)
257
+ resolve(data.outcome)
258
+ }
236
259
  })
237
260
  }
238
261
 
@@ -275,7 +298,7 @@ function newRequestId(): string {
275
298
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
276
299
  return crypto.randomUUID()
277
300
  }
278
- return `modal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
301
+ return `request_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
279
302
  }
280
303
 
281
304
  /** Post OPEN_MODAL and resolve when the host answers MODAL_CLOSED. */
@@ -284,7 +307,7 @@ function openModal(
284
307
  props?: Record<string, unknown>,
285
308
  options?: OpenModalOptions,
286
309
  ): Promise<unknown> {
287
- ensureModalListener()
310
+ ensureReplyListener()
288
311
  const requestId = newRequestId()
289
312
  const envelope: Envelope<OpenModalMessage> = {
290
313
  source: MESSAGE_SOURCE,
@@ -306,6 +329,32 @@ function openModal(
306
329
  })
307
330
  }
308
331
 
332
+ /** Post RUN_PAGE_ACTION and resolve when the host answers PAGE_ACTION_SETTLED.
333
+ * The host's dialogs take focus, so it goes back to the pressed control
334
+ * afterwards — as for a modal. */
335
+ function runPageAction(
336
+ action: PageAction,
337
+ context?: PageActionContext,
338
+ ): Promise<PageActionOutcome> {
339
+ ensureReplyListener()
340
+ const requestId = newRequestId()
341
+ const envelope: Envelope<RunPageActionMessage> = {
342
+ source: MESSAGE_SOURCE,
343
+ type: 'RUN_PAGE_ACTION',
344
+ requestId,
345
+ action,
346
+ ...(context !== undefined ? { context } : {}),
347
+ }
348
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
349
+ return new Promise<PageActionOutcome>((resolve) => {
350
+ pendingPageActions.set(requestId, (outcome) => {
351
+ restoreFocus(opener)
352
+ resolve(outcome)
353
+ })
354
+ window.parent?.postMessage(envelope, '*')
355
+ })
356
+ }
357
+
309
358
  /** Post MODAL_RESULT — the component inside a host modal closing with a value. */
310
359
  export function postModalResult(requestId: string, result?: unknown): void {
311
360
  const envelope: Envelope<ModalResultMessage> = {
@@ -329,7 +378,7 @@ export function RuntimeProvider({ value, children }: { value: RuntimeValue; chil
329
378
  // props and pageCtx are all set once), so memoizing on its identity recreates
330
379
  // the context only when the runtime genuinely re-inits.
331
380
  const ctx = useMemo<RuntimeContextValue>(
332
- () => ({ ...value, navigate, openCall, openModal, agentWindow }),
381
+ () => ({ ...value, navigate, openCall, openModal, agentWindow, runPageAction }),
333
382
  [value],
334
383
  )
335
384
  return <RuntimeContext.Provider value={ctx}>{children}</RuntimeContext.Provider>
@@ -10,12 +10,18 @@ import {
10
10
  useModalHost,
11
11
  useNavigate,
12
12
  useOverlayExpansion,
13
+ usePageAction,
13
14
  usePageContext,
14
15
  useProps,
15
16
  useRuntime,
16
17
  useSdk,
17
18
  } from './hooks'
18
- import { type Envelope, MESSAGE_SOURCE, type ModalClosedMessage } from './protocol'
19
+ import {
20
+ type Envelope,
21
+ MESSAGE_SOURCE,
22
+ type ModalClosedMessage,
23
+ type PageActionSettledMessage,
24
+ } from './protocol'
19
25
 
20
26
  // A stable stand-in for the injected SDK — the hooks only pass it through.
21
27
  const sdk = {} as RuntimeValue['sdk']
@@ -24,7 +30,7 @@ function makeValue(overrides: Partial<RuntimeValue> = {}): RuntimeValue {
24
30
  return {
25
31
  sdk,
26
32
  props: { title: 'Hello' },
27
- pageCtx: { record: { id: 'rec_1', name: 'ACME' }, entity: { slug: 'company' } },
33
+ pageCtx: { record: { id: 'rec_1', name: 'ACME' }, entity: { slug: 'company' }, mode: 'view' },
28
34
  apiBaseUrl: 'https://api.test',
29
35
  getToken: () => 'tok_123',
30
36
  ...overrides,
@@ -57,11 +63,12 @@ describe('runtime hooks', () => {
57
63
  expect(result.current.props.title).toBe('Hello')
58
64
  expect(result.current.ctx.record).toEqual({ id: 'rec_1', name: 'ACME' })
59
65
  expect(result.current.ctx.entity?.slug).toBe('company')
66
+ expect(result.current.ctx.mode).toBe('view')
60
67
  expect(result.current.base).toBe('https://api.test')
61
68
  })
62
69
 
63
70
  it('usePageContext().record is undefined when the page has no bound record', () => {
64
- const value = makeValue({ pageCtx: { entity: { slug: 'dashboard' } } })
71
+ const value = makeValue({ pageCtx: { entity: { slug: 'dashboard' }, mode: 'view' } })
65
72
  const { result } = renderHook(() => usePageContext(), { wrapper: wrapper(value) })
66
73
  expect(result.current.record).toBeUndefined()
67
74
  })
@@ -103,6 +110,18 @@ describe('runtime hooks', () => {
103
110
  '*',
104
111
  )
105
112
 
113
+ result.current.open({ agentKey: 'sales-bot', prompt: 'Follow up', context: 'Deal #7' })
114
+ expect(post).toHaveBeenLastCalledWith(
115
+ {
116
+ source: MESSAGE_SOURCE,
117
+ type: 'OPEN_AGENT_WINDOW',
118
+ agentKey: 'sales-bot',
119
+ prompt: 'Follow up',
120
+ context: 'Deal #7',
121
+ },
122
+ '*',
123
+ )
124
+
106
125
  result.current.close()
107
126
  expect(post).toHaveBeenLastCalledWith(
108
127
  { source: MESSAGE_SOURCE, type: 'CLOSE_AGENT_WINDOW' },
@@ -188,6 +207,65 @@ describe('runtime hooks', () => {
188
207
  opener.remove()
189
208
  })
190
209
 
210
+ it('usePageAction posts RUN_PAGE_ACTION and resolves with the matching outcome', async () => {
211
+ const post = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => {})
212
+ const { result } = renderHook(() => usePageAction(), { wrapper: wrapper(makeValue()) })
213
+ const action = {
214
+ kind: 'intent' as const,
215
+ intent: 'update_record' as const,
216
+ entity: 'deal',
217
+ label: 'Update deal',
218
+ icon: 'Sparkles',
219
+ prompt: 'Apply what was agreed',
220
+ }
221
+
222
+ const pending = result.current(action, {
223
+ scope: { notes: 'Signed' },
224
+ records: [{ entitySlug: 'deal', recordId: 'deal_1' }],
225
+ })
226
+ const [payload] = post.mock.calls.at(-1) as [Record<string, unknown>]
227
+ expect(payload).toMatchObject({
228
+ source: MESSAGE_SOURCE,
229
+ type: 'RUN_PAGE_ACTION',
230
+ action,
231
+ context: {
232
+ scope: { notes: 'Signed' },
233
+ records: [{ entitySlug: 'deal', recordId: 'deal_1' }],
234
+ },
235
+ })
236
+
237
+ const reply = (requestId: string): Envelope<PageActionSettledMessage> => ({
238
+ source: MESSAGE_SOURCE,
239
+ type: 'PAGE_ACTION_SETTLED',
240
+ requestId,
241
+ outcome: {
242
+ status: 'done',
243
+ kind: 'intent',
244
+ mode: 'update',
245
+ entitySlug: 'deal',
246
+ recordId: 'deal_1',
247
+ title: 'Acme renewal',
248
+ },
249
+ })
250
+ // Another request's reply — and a modal's with our id — must not resolve ours.
251
+ window.dispatchEvent(new MessageEvent('message', { data: reply('someone-else') }))
252
+ window.dispatchEvent(
253
+ new MessageEvent('message', {
254
+ data: { source: MESSAGE_SOURCE, type: 'MODAL_CLOSED', requestId: payload.requestId },
255
+ }),
256
+ )
257
+ window.dispatchEvent(new MessageEvent('message', { data: reply(payload.requestId as string) }))
258
+ await expect(pending).resolves.toMatchObject({ status: 'done', recordId: 'deal_1' })
259
+ })
260
+
261
+ it('usePageAction leaves out a context it was not given', () => {
262
+ const post = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => {})
263
+ const { result } = renderHook(() => usePageAction(), { wrapper: wrapper(makeValue()) })
264
+ void result.current({ kind: 'link', url: 'https://example.com', label: 'Open', icon: 'Link' })
265
+ const [payload] = post.mock.calls.at(-1) as [Record<string, unknown>]
266
+ expect(payload).not.toHaveProperty('context')
267
+ })
268
+
191
269
  it('useComponentModal carries the width and height the opener asked for', () => {
192
270
  const post = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => {})
193
271
  const { result } = renderHook(() => useComponentModal(), { wrapper: wrapper(makeValue()) })
package/src/hooks.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PageAction } from '@proteos/sdk'
1
2
  import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
2
3
 
3
4
  import {
@@ -10,7 +11,7 @@ import {
10
11
  RuntimeContext,
11
12
  type RuntimeContextValue,
12
13
  } from './context'
13
- import type { PageContext } from './protocol'
14
+ import type { PageActionContext, PageActionOutcome, PageContext } from './protocol'
14
15
 
15
16
  /**
16
17
  * Low-level escape hatch: the whole runtime context (sdk, props, pageCtx,
@@ -41,9 +42,13 @@ export function useProps<T extends Record<string, unknown> = Record<string, unkn
41
42
  }
42
43
 
43
44
  /**
44
- * The full page context: host `record`, `entity`, and URL `params`. The
45
- * `record` is a snapshot taken at mount (`undefined` on record-less platform
46
- * pages) — fetch via `useSdk().data` for anything that must stay fresh.
45
+ * The full page context: host `record`, `entity`, URL `params`, the viewer as
46
+ * `current_user` (absent on public pages) and the page `mode`
47
+ * (`'view' | 'create' | 'edit'` — what the viewer is doing with the record).
48
+ * The `record` is a snapshot taken at mount (`undefined` on record-less
49
+ * platform pages) — fetch via `useSdk().data` for anything that must stay
50
+ * fresh. `mode` is a snapshot too: the host remounts the frame when the page
51
+ * switches between view and edit.
47
52
  */
48
53
  export function usePageContext(): PageContext {
49
54
  return useRuntime().pageCtx
@@ -156,6 +161,45 @@ export function useComponentModal(): (
156
161
  return useRuntime().openModal
157
162
  }
158
163
 
164
+ /**
165
+ * Run a page action from a component's own button — the very `PageAction` a
166
+ * page or list toolbar carries, defined inline. The HOST runs it exactly as a
167
+ * toolbar press: `action` / `workflow` open the invoke dialog, `intent`
168
+ * prepares a record proposal for review, `agent` opens the agent window,
169
+ * `link` opens a new tab. Resolves once that flow is over.
170
+ *
171
+ * ```ts
172
+ * const runPageAction = usePageAction()
173
+ * const outcome = await runPageAction(
174
+ * {
175
+ * kind: 'intent',
176
+ * intent: 'update_record',
177
+ * entity: 'deal',
178
+ * label: 'Update deal',
179
+ * icon: 'Sparkles',
180
+ * prompt: 'Apply what was agreed',
181
+ * context: '{{ notes }}',
182
+ * },
183
+ * { scope: { notes }, records: [{ entitySlug: 'deal', recordId: deal.id }] },
184
+ * )
185
+ * if (outcome.status === 'done' && outcome.kind === 'intent') refetch()
186
+ * ```
187
+ *
188
+ * The action's Liquid templates render against the page scope (`record`,
189
+ * `entity`, `params`, `mode`, `current_user`) with `context.scope` merged over
190
+ * it; `context.records` says what it acts on (default: the page record). See
191
+ * {@link PageActionContext} and {@link PageActionOutcome}. `visible_when`
192
+ * still applies — an action whose rule does not match resolves
193
+ * `unavailable: hidden` without opening anything. One page action at a time;
194
+ * public pages and previews resolve `unavailable: unsupported`.
195
+ */
196
+ export function usePageAction(): (
197
+ action: PageAction,
198
+ context?: PageActionContext,
199
+ ) => Promise<PageActionOutcome> {
200
+ return useRuntime().runPageAction
201
+ }
202
+
159
203
  export interface ModalHost {
160
204
  /** True when this component is rendered as the content of a host modal. */
161
205
  isInModal: boolean
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  useNavigate,
31
31
  useOpenCall,
32
32
  useOverlayExpansion,
33
+ usePageAction,
33
34
  usePageContext,
34
35
  useProps,
35
36
  useRuntime,
@@ -59,8 +60,15 @@ export {
59
60
  type OverlayRect,
60
61
  type OverlayRectMessage,
61
62
  type OverlayStateMessage,
63
+ type PageActionContext,
64
+ type PageActionOutcome,
65
+ type PageActionRecord,
66
+ type PageActionSettledMessage,
62
67
  type PageContext,
68
+ type PageMode,
63
69
  type ReadyMessage,
64
70
  type ResizeMessage,
71
+ type RunPageActionMessage,
65
72
  type RuntimeMessage,
73
+ type TriggerPhase,
66
74
  } from './protocol'