@proteos/component-sdk 0.61.0 → 0.76.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.
- package/dist/{chunk-NOCSMXCW.js → chunk-IYXDPNMJ.js} +2 -2
- package/dist/chunk-IYXDPNMJ.js.map +1 -0
- package/dist/{chunk-HD5ADY7F.cjs → chunk-Q7PTW7DD.cjs} +2 -2
- package/dist/chunk-Q7PTW7DD.cjs.map +1 -0
- package/dist/index.cjs +59 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -7
- package/dist/index.d.ts +51 -7
- package/dist/index.js +49 -16
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs +5 -5
- package/dist/protocol.d.cts +161 -3
- package/dist/protocol.d.ts +161 -3
- package/dist/protocol.js +1 -1
- package/package.json +4 -4
- package/src/context.tsx +65 -16
- package/src/hooks.test.tsx +81 -3
- package/src/hooks.ts +48 -4
- package/src/index.ts +8 -0
- package/src/protocol.ts +154 -0
- package/dist/chunk-HD5ADY7F.cjs.map +0 -1
- package/dist/chunk-NOCSMXCW.js.map +0 -1
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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.
|
|
3
|
+
"version": "0.76.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.
|
|
45
|
+
"@proteos/sdk": "^0.76.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@tanstack/react-query": ">=5.0.0",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"tsup": "^8.3.0",
|
|
61
61
|
"typescript": "^6.0.3",
|
|
62
62
|
"vitest": "^3.2.4",
|
|
63
|
-
"@proteos/
|
|
64
|
-
"@proteos/
|
|
63
|
+
"@proteos/tsconfig": "0.0.0",
|
|
64
|
+
"@proteos/biome-config": "0.0.0"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
67
|
"build": "tsup",
|
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
|
-
// ──
|
|
219
|
-
// One module-level listener + a pending map, rather than a
|
|
220
|
-
// the SDK is an import-mapped singleton, so this is
|
|
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
|
-
|
|
236
|
+
const pendingPageActions = new Map<string, (outcome: PageActionOutcome) => void>()
|
|
237
|
+
let replyListenerAttached = false
|
|
224
238
|
|
|
225
|
-
function
|
|
226
|
-
if (
|
|
227
|
-
|
|
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)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 `
|
|
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
|
-
|
|
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>
|
package/src/hooks.test.tsx
CHANGED
|
@@ -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 {
|
|
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`,
|
|
45
|
-
* `
|
|
46
|
-
*
|
|
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'
|