@datalayer/agent-runtimes 1.3.38 → 1.3.40

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.
@@ -2812,7 +2812,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2812
2812
  // ========================================================================
2813
2813
  // handleSend
2814
2814
  // ========================================================================
2815
- const handleSend = useCallback(async (messageOverride) => {
2815
+ const handleSend = useCallback(async (messageOverride,
2816
+ // AG-UI's: what goes with the run besides the conversation — an
2817
+ // application's page says there what it did (LOOP R-04).
2818
+ forwardedProps) => {
2816
2819
  const messageContent = (messageOverride ?? input).trim();
2817
2820
  if (!messageContent || isLoading)
2818
2821
  return;
@@ -2915,6 +2918,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2915
2918
  builtinTools: enabledMcpToolNames,
2916
2919
  skills: enabledSkillIds,
2917
2920
  identities: connectedIdentitiesRef.current,
2921
+ ...(forwardedProps ? { forwardedProps } : {}),
2918
2922
  });
2919
2923
  }
2920
2924
  }
@@ -3099,8 +3103,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3099
3103
  return undefined;
3100
3104
  }
3101
3105
  onSendReady({
3102
- send: (message) => {
3103
- void handleSend(message);
3106
+ send: (message, forwardedProps) => {
3107
+ void handleSend(message, forwardedProps);
3104
3108
  },
3105
3109
  stop: handleStop,
3106
3110
  // The same reset the header's + performs, for a host whose controls
@@ -17,3 +17,10 @@ export declare function withVisibleWhen(component: ReactComponentImplementation)
17
17
  * it — with `visible_when` on every component.
18
18
  */
19
19
  export declare const datalayerCatalog: Catalog<ReactComponentImplementation, import("@a2ui/web_core/v0_9").FunctionImplementation>;
20
+ /**
21
+ * Datalayer's catalog narrowed to the components named — what the enabled
22
+ * plugins contribute as blocks (LOOP R-01b, `loop.canvas.block`), so that an
23
+ * application's page draws what its Canvas can place and nothing else. Under
24
+ * the same id; a name the catalog does not draw is an error.
25
+ */
26
+ export declare function catalogOfBlocks(names: readonly string[]): Catalog<ReactComponentImplementation>;
@@ -55,3 +55,16 @@ export function withVisibleWhen(component) {
55
55
  * it — with `visible_when` on every component.
56
56
  */
57
57
  export const datalayerCatalog = new Catalog(basicCatalog.id, basicCatalog.protocolVersion, [...basicCatalog.components.values(), ...OWN_COMPONENTS].map(withVisibleWhen), [...basicCatalog.functions.values()], basicCatalog.themeSchema, basicCatalog.instructions);
58
+ /**
59
+ * Datalayer's catalog narrowed to the components named — what the enabled
60
+ * plugins contribute as blocks (LOOP R-01b, `loop.canvas.block`), so that an
61
+ * application's page draws what its Canvas can place and nothing else. Under
62
+ * the same id; a name the catalog does not draw is an error.
63
+ */
64
+ export function catalogOfBlocks(names) {
65
+ const unknown = names.filter(name => !datalayerCatalog.components.has(name));
66
+ if (unknown.length > 0) {
67
+ throw new Error(`No renderer draws ${unknown.join(', ')}: a block is contributed by a plugin whose component the catalog draws.`);
68
+ }
69
+ return new Catalog(datalayerCatalog.id, datalayerCatalog.protocolVersion, names.map(name => datalayerCatalog.components.get(name)), [...datalayerCatalog.functions.values()], datalayerCatalog.themeSchema, datalayerCatalog.instructions);
70
+ }
@@ -1,6 +1,45 @@
1
- import type { ReactorPlugin } from '@datalayer/reactor';
1
+ /**
2
+ * An application, rendered: the LOOP workspace configured from an Appspec
3
+ * (LOOP R-01).
4
+ *
5
+ * One renderer for every place an application appears — the Studio's
6
+ * Preview, its hosted page, an embed, the home page — so that what a builder
7
+ * sees while building is what its users get. It is not a second shell: it is
8
+ * `LoopEmbed`, given
9
+ *
10
+ * - an **application plugin** (`defineAppPlugin`): the agent that answers,
11
+ * created with the application in its payload — so the runtime reaches only
12
+ * what the application connects to and enforces its rules before every tool
13
+ * call — and the application's starters as the openers of the empty chat;
14
+ * - its **layout**, as `interface.layout` says (`appLayoutOptions`, LOOP
15
+ * T-07), on the workspace's own `page-layout` plugin: `chat` is the
16
+ * conversation alone, with the A2UI surfaces its answers draw; `page` is
17
+ * the page on a sheet with the composer over it and the conversation in a
18
+ * panel the composer's display modes open; `split` is the conversation and
19
+ * the page side by side, a hairline between them to drag;
20
+ * - its **page**, when it has one (`hasAppPage`: a chat, a widget or a worker
21
+ * with a `page` or `split` layout): its A2UI surface drawn by the
22
+ * `app-page` plugin, in place of the notebook and the document, fed from
23
+ * the conversation and answered through it (`APP_KIND_PATHS`), with the
24
+ * Canvas's block plugins (`CANVAS_BLOCK_PLUGINS`), whose blocks are what
25
+ * the page draws (R-01b);
26
+ * - a **decision**: a workspace without a conversation whose one view is the
27
+ * page its host draws (`page`, R-02) — the Studio's run page, at its
28
+ * address and embedded;
29
+ * - its **frame**, when the host asks (`frame`): the `window-frame` plugin's
30
+ * window, its title the application's face and name.
31
+ *
32
+ * Which plugins a kind needs, and how its workspace is laid out, is one
33
+ * function: `appPreset`, the preset per kind, as `loopPlugins` is for the
34
+ * examples.
35
+ *
36
+ * @module loop/apps/AppRenderer
37
+ */
38
+ import { type ComponentType } from 'react';
39
+ import type { PluginRef, ReactorPlugin } from '@datalayer/reactor';
2
40
  import type { AppSpec } from '../../types/agentspecs';
3
41
  import type { ThemeOverrides } from '../../types/chat';
42
+ import { type AppHostPageProps } from '../plugins/app-page';
4
43
  import { type LoopEmbedProps } from '../embed/LoopEmbed';
5
44
  import type { LoopPresetOptions } from '../presets';
6
45
  import type { PresenceState } from '../../chat/presence/presenceStatus';
@@ -44,6 +83,19 @@ export type AppRendererProps = Omit<LoopEmbedProps, 'agentId'> & {
44
83
  * the application's face and name. A host's own `frameTitle` wins.
45
84
  */
46
85
  frame?: boolean;
86
+ /**
87
+ * The page a host draws for a kind the workspace has no page for: a
88
+ * decision's (R-02). The workspace's one view. A component defined once,
89
+ * outside the host's render: a new one would be a new plugin, and restart
90
+ * the workspace.
91
+ */
92
+ page?: ComponentType<AppHostPageProps>;
93
+ /**
94
+ * The UI plugins its organization has turned off (`plugins_off`, catalogue
95
+ * ids such as 'a2ui'): their blocks are off its page as they are off its
96
+ * Canvas's palette. Compared by content.
97
+ */
98
+ pluginsOff?: readonly string[];
47
99
  };
48
100
  /** What `interface.layout` sets on the workspace. */
49
101
  export type AppLayoutOptions = Pick<LoopPresetOptions, 'editors' | 'showViewSelector' | 'defaultEditor' | 'pageLayout' | 'pageLayoutArrangement' | 'pageLayoutPrompt' | 'pageLayoutPromptAnchor' | 'pageLayoutTurnPanelFooter'>;
@@ -62,6 +114,31 @@ export type AppLayoutOptions = Pick<LoopPresetOptions, 'editors' | 'showViewSele
62
114
  * it; without one (a decision, whose page is the Studio's), the editors stay.
63
115
  */
64
116
  export declare function appLayoutOptions(app: Pick<AppSpec, 'kind' | 'interface' | 'agent'>): AppLayoutOptions;
117
+ /** What a kind of application is drawn with: its plugins, and its workspace. */
118
+ export type AppPreset = {
119
+ plugins: PluginRef[];
120
+ workspace: AppLayoutOptions & Pick<LoopPresetOptions, 'conversation'>;
121
+ };
122
+ /**
123
+ * The plugins an application's kind needs, and how its workspace is laid
124
+ * out (LOOP R-01): the preset per kind, as `loopPlugins` is for the examples.
125
+ *
126
+ * - a **chat**, a **widget**, a **worker**: its agent (`defineAppPlugin`);
127
+ * its page (`app-page`) when it has one, with the Canvas's block plugins
128
+ * but those of the UI plugins its organization turned off (`pluginsOff`),
129
+ * whose contributions are the blocks it may draw (R-01b); a thumb and a
130
+ * comment on each answer when its record keeps feedback (V-18); laid out
131
+ * as `interface.layout` says;
132
+ * - a **decision**: the page its host draws (`page`), as the one view of a
133
+ * workspace without a conversation (R-02).
134
+ *
135
+ * An application that cannot be drawn is refused with a sentence: a
136
+ * decision without its host's page, an application run by a team.
137
+ */
138
+ export declare function appPreset(app: AppSpec, options?: {
139
+ page?: ComponentType<AppHostPageProps>;
140
+ pluginsOff?: readonly string[];
141
+ }): AppPreset;
65
142
  /**
66
143
  * What an application's agent is created with on a Datalayer runtime: under
67
144
  * the application's id, over AG-UI, with the application's own document —
@@ -87,5 +164,5 @@ export declare const DATALAYER_BOOTSTRAP_AGENTSPEC = "example-simple";
87
164
  * what the page around it set does not reach in.
88
165
  */
89
166
  export declare function appThemeOverrides(app: AppSpec): ThemeOverrides | undefined;
90
- export declare function AppRenderer({ app, plugins, instance, onPresence, frame, ...embed }: AppRendererProps): React.JSX.Element;
167
+ export declare function AppRenderer({ app, plugins, instance, onPresence, frame, page, pluginsOff, ...embed }: AppRendererProps): React.JSX.Element;
91
168
  export default AppRenderer;
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /*
3
3
  * Copyright (c) 2025-2026 Datalayer, Inc.
4
4
  * Distributed under the terms of the Modified BSD License.
@@ -25,18 +25,26 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
25
25
  * - its **page**, when it has one (`hasAppPage`: a chat, a widget or a worker
26
26
  * with a `page` or `split` layout): its A2UI surface drawn by the
27
27
  * `app-page` plugin, in place of the notebook and the document, fed from
28
- * the conversation and answered through it (`APP_KIND_PATHS`). A
29
- * decision's page stays the Studio's run page (R-02); its layout keeps the
30
- * editors;
28
+ * the conversation and answered through it (`APP_KIND_PATHS`), with the
29
+ * Canvas's block plugins (`CANVAS_BLOCK_PLUGINS`), whose blocks are what
30
+ * the page draws (R-01b);
31
+ * - a **decision**: a workspace without a conversation whose one view is the
32
+ * page its host draws (`page`, R-02) — the Studio's run page, at its
33
+ * address and embedded;
31
34
  * - its **frame**, when the host asks (`frame`): the `window-frame` plugin's
32
35
  * window, its title the application's face and name.
33
36
  *
37
+ * Which plugins a kind needs, and how its workspace is laid out, is one
38
+ * function: `appPreset`, the preset per kind, as `loopPlugins` is for the
39
+ * examples.
40
+ *
34
41
  * @module loop/apps/AppRenderer
35
42
  */
36
43
  import { useMemo } from 'react';
37
44
  import { loopAccentStyles } from '@datalayer/primer-addons';
38
45
  import { defineAgentCapacityPlugin } from '../plugins/agent-capacity';
39
- import { APP_PAGE_SURFACE, defineAppPagePlugin, hasAppPage, } from '../plugins/app-page';
46
+ import { APP_PAGE_SURFACE, defineAppHostPagePlugin, defineAppPagePlugin, hasAppPage, } from '../plugins/app-page';
47
+ import { CANVAS_BLOCK_PLUGINS, canvasBlocksPluginName, } from '../plugins/canvas-blocks';
40
48
  import { LoopEmbed } from '../embed/LoopEmbed';
41
49
  import { dumpAppspec } from './appspec';
42
50
  import { defineAppFeedbackPlugin } from './AppFeedback';
@@ -118,6 +126,51 @@ export function appLayoutOptions(app) {
118
126
  pageLayoutTurnPanelFooter: 'actions',
119
127
  };
120
128
  }
129
+ /**
130
+ * The plugins an application's kind needs, and how its workspace is laid
131
+ * out (LOOP R-01): the preset per kind, as `loopPlugins` is for the examples.
132
+ *
133
+ * - a **chat**, a **widget**, a **worker**: its agent (`defineAppPlugin`);
134
+ * its page (`app-page`) when it has one, with the Canvas's block plugins
135
+ * but those of the UI plugins its organization turned off (`pluginsOff`),
136
+ * whose contributions are the blocks it may draw (R-01b); a thumb and a
137
+ * comment on each answer when its record keeps feedback (V-18); laid out
138
+ * as `interface.layout` says;
139
+ * - a **decision**: the page its host draws (`page`), as the one view of a
140
+ * workspace without a conversation (R-02).
141
+ *
142
+ * An application that cannot be drawn is refused with a sentence: a
143
+ * decision without its host's page, an application run by a team.
144
+ */
145
+ export function appPreset(app, options = {}) {
146
+ if (app.kind === 'decision') {
147
+ if (!options.page) {
148
+ throw new Error(`“${app.name}” is a decision, whose page is drawn by where it runs: none was given here.`);
149
+ }
150
+ return {
151
+ plugins: [defineAppHostPagePlugin(app, options.page)],
152
+ workspace: {
153
+ conversation: false,
154
+ editors: false,
155
+ showViewSelector: false,
156
+ },
157
+ };
158
+ }
159
+ const withPage = hasAppPage(app);
160
+ // The blocks its Canvas offers: those of the UI plugins not turned off.
161
+ const off = new Set((options.pluginsOff ?? []).map(canvasBlocksPluginName));
162
+ const blocks = CANVAS_BLOCK_PLUGINS.filter(plugin => !off.has(plugin.name));
163
+ return {
164
+ plugins: [
165
+ defineAppPlugin(app),
166
+ ...(withPage ? [defineAppPagePlugin(app), ...blocks] : []),
167
+ // A thumb and a comment on each answer, kept in its record (LOOP
168
+ // V-18): only for an application whose record keeps feedback.
169
+ ...(keepsFeedback(app) ? [defineAppFeedbackPlugin(app)] : []),
170
+ ],
171
+ workspace: appLayoutOptions(app),
172
+ };
173
+ }
121
174
  /**
122
175
  * What an application's agent is created with on a Datalayer runtime: under
123
176
  * the application's id, over AG-UI, with the application's own document —
@@ -174,52 +227,50 @@ export function appThemeOverrides(app) {
174
227
  }
175
228
  : undefined;
176
229
  }
177
- export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, frame = false, ...embed }) {
230
+ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, frame = false, page, pluginsOff, ...embed }) {
178
231
  /*
179
- * The application plugin, made once per application. `LoopEmbed` rebuilds
180
- * its whole reactor when its plugins change, so a new plugin on every
181
- * render of the host would restart the running workspace. The key is the
232
+ * The kind's plugins, made once per application. `LoopEmbed` rebuilds its
233
+ * whole reactor when its plugins change, so a new plugin on every render
234
+ * of the host would restart the running workspace. The key is the
182
235
  * application as its file holds it: a change to it is a new application.
183
236
  */
184
237
  const source = JSON.stringify(dumpAppspec(app));
185
- const appPlugin = useMemo(() => (app.agent ? defineAppPlugin(app) : null),
186
- // eslint-disable-next-line react-hooks/exhaustive-deps
187
- [source]);
188
- const withPage = hasAppPage(app);
189
- const pagePlugin = useMemo(() => (withPage ? defineAppPagePlugin(app) : null),
190
- // eslint-disable-next-line react-hooks/exhaustive-deps
191
- [source, withPage]);
192
- /*
193
- * A thumb and a comment on each answer, kept in its record (LOOP V-18):
194
- * only for an application whose record keeps feedback.
195
- */
196
- const keeps = keepsFeedback(app);
197
- const feedbackPlugin = useMemo(() => (keeps ? defineAppFeedbackPlugin(app) : null),
238
+ const off = [...(pluginsOff ?? [])].sort().join(',');
239
+ const preset = useMemo(() => {
240
+ try {
241
+ return appPreset(app, { page, pluginsOff });
242
+ }
243
+ catch (error) {
244
+ return {
245
+ problem: error instanceof Error ? error.message : String(error),
246
+ };
247
+ }
248
+ },
198
249
  // eslint-disable-next-line react-hooks/exhaustive-deps
199
- [source, keeps]);
200
- const allPlugins = useMemo(() => appPlugin
201
- ? [
202
- appPlugin,
203
- ...(pagePlugin ? [pagePlugin] : []),
204
- ...(feedbackPlugin ? [feedbackPlugin] : []),
205
- ...plugins,
206
- ]
207
- : plugins, [appPlugin, pagePlugin, feedbackPlugin, plugins]);
250
+ [source, page, off]);
251
+ const allPlugins = useMemo(() => ('problem' in preset ? plugins : [...preset.plugins, ...plugins]), [preset, plugins]);
208
252
  /*
209
253
  * On Datalayer, the application runs on a runtime: allocated with a plain
210
254
  * agentspec, its agent created there with the application's spec — so that
211
255
  * the runtime registers it and decides every tool call by its rules (LOOP
212
256
  * R-03, R-05), as `loop apps run --cloud` does.
213
257
  */
214
- const datalayerCreatePayload = useMemo(() => (app.agent ? appDatalayerCreatePayload(app, instance) : undefined),
258
+ const conversation = !('problem' in preset) && preset.workspace.conversation !== false;
259
+ const datalayerCreatePayload = useMemo(() => (conversation ? appDatalayerCreatePayload(app, instance) : undefined),
215
260
  // eslint-disable-next-line react-hooks/exhaustive-deps
216
- [source, instance?.appUid, instance?.deploymentUid, instance?.version]);
261
+ [
262
+ source,
263
+ conversation,
264
+ instance?.appUid,
265
+ instance?.deploymentUid,
266
+ instance?.version,
267
+ ]);
217
268
  const accent = app.interface?.accent;
218
269
  const themeOverrides = useMemo(() => appThemeOverrides(app),
219
270
  // eslint-disable-next-line react-hooks/exhaustive-deps
220
271
  [accent]);
221
- if (!appPlugin) {
222
- return (_jsxs("div", { role: "status", style: { padding: 16 }, children: ["\u201C", app.name, "\u201D is run by a team, which the workspace does not run as an application yet."] }));
272
+ if ('problem' in preset) {
273
+ return (_jsx("div", { role: "status", style: { padding: 16 }, children: preset.problem }));
223
274
  }
224
275
  return (_jsx(LoopEmbed
225
276
  // The application's own agent: created under its id, so that its
@@ -227,7 +278,7 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, f
227
278
  , {
228
279
  // The application's own agent: created under its id, so that its
229
280
  // spec is applied to an agent of its own, never to the one it extends.
230
- agentId: app.id, ...appLayoutOptions(app), ...(frame
281
+ agentId: app.id, ...preset.workspace, ...(frame
231
282
  ? {
232
283
  frameTitle: [app.emoji, app.name].filter(Boolean).join(' '),
233
284
  }
@@ -227,8 +227,12 @@ export type ViewControls = {
227
227
  * rendered surface, whose values the agent should answer. The chat had
228
228
  * this control from the start and kept it to itself; a host with a
229
229
  * message had no way in.
230
+ *
231
+ * `forwardedProps` go with the run as AG-UI's: an application's page says
232
+ * there what it did besides the message (`loop`, LOOP R-04). Answers why
233
+ * the message cannot go, when it cannot.
230
234
  */
231
- send?: (message: string) => void;
235
+ send?: (message: string, forwardedProps?: Record<string, unknown>) => string | void;
232
236
  };
233
237
  /** Props every view receives. */
234
238
  export type LoopViewProps = {
@@ -80,7 +80,8 @@ function useServerAgent(app, serverUrl) {
80
80
  setState({
81
81
  protocol: {
82
82
  type: 'ag-ui',
83
- endpoint: `${base}/api/v1/ag-ui/${app.id}/`,
83
+ // Its session API: each thread a session (LOOP R-04).
84
+ endpoint: `${base}/api/v1/apps/agents/${encodeURIComponent(app.id)}/ag-ui/`,
84
85
  agentId: app.id,
85
86
  },
86
87
  });
@@ -137,7 +138,8 @@ function useDatalayerAgent(app, instance) {
137
138
  return {
138
139
  protocol: {
139
140
  type: 'ag-ui',
140
- endpoint: `${runtime.agentBaseUrl}/api/v1/ag-ui/${app.id}/`,
141
+ // Its session API: each thread a session (LOOP R-04).
142
+ endpoint: `${runtime.agentBaseUrl}/api/v1/apps/agents/${encodeURIComponent(app.id)}/ag-ui/`,
141
143
  agentId: app.id,
142
144
  authToken: token,
143
145
  },
@@ -100,6 +100,7 @@ export function LoopEmbed({ agentId = '', showHeader = false, chatHeaderActions,
100
100
  preset.floatingPrompt,
101
101
  preset.editorSelector,
102
102
  preset.editors,
103
+ preset.conversation,
103
104
  preset.graph,
104
105
  preset.commandPalette,
105
106
  preset.pluginsPanel,
@@ -13,13 +13,14 @@
13
13
  * an action is handed the surface it came from, so that what a block wrote
14
14
  * into its data model can be read when a button is pressed. It draws from
15
15
  * Datalayer's catalog: the basic one, any block shown only while its
16
- * `visible_when` holds.
16
+ * `visible_when` holds — or, given one, a catalog narrowed to the blocks
17
+ * the workspace's plugins contribute (`catalogOfBlocks`).
17
18
  *
18
19
  * @module loop/plugins/a2ui-surface/InlineSurface
19
20
  */
20
21
  import type { JSX } from 'react';
21
22
  import type { ReactComponentImplementation } from '@a2ui/react/v0_9';
22
- import { type A2uiClientAction, type A2uiMessage, type SurfaceModel } from '@a2ui/web_core/v0_9';
23
+ import { Catalog, type A2uiClientAction, type A2uiMessage, type SurfaceModel } from '@a2ui/web_core/v0_9';
23
24
  /** A surface as the renderer holds it: its data model is `dataModel`. */
24
25
  export type InlineSurfaceModel = SurfaceModel<ReactComponentImplementation>;
25
26
  type Surface = InlineSurfaceModel;
@@ -27,8 +28,10 @@ type Surface = InlineSurfaceModel;
27
28
  export declare const SURFACE_CATALOG_ID: string;
28
29
  /** Values to publish into a surface, by path. */
29
30
  export type InlineSurfaceData = Record<string, unknown>;
30
- export declare function InlineSurface({ messages, onAction, validationError, data, }: {
31
+ export declare function InlineSurface({ messages, onAction, validationError, data, catalog, }: {
31
32
  messages: A2uiMessage[];
33
+ /** What it draws with, read once: Datalayer's catalog unless given. */
34
+ catalog?: Catalog<ReactComponentImplementation>;
32
35
  /** Told of an action, with the surface it came from. */
33
36
  onAction: (action: A2uiClientAction, surface?: Surface) => void;
34
37
  validationError?: string | null;
@@ -11,12 +11,15 @@ const INHERIT_THEME = {
11
11
  ['--a2ui-color-primary']: 'var(--fgColor-accent)',
12
12
  ['--a2ui-color-outline']: 'var(--borderColor-default)',
13
13
  };
14
- export function InlineSurface({ messages, onAction, validationError, data, }) {
14
+ export function InlineSurface({ messages, onAction, validationError, data, catalog = datalayerCatalog, }) {
15
15
  // Reached through a ref: the processor is built once, and the handler it
16
16
  // was built with must not go stale when the host's does not.
17
17
  const onActionRef = useRef(onAction);
18
18
  onActionRef.current = onAction;
19
- const processor = useMemo(() => new MessageProcessor([datalayerCatalog], action => onActionRef.current(action, processorRef.current?.model.getSurface(action.surfaceId))), []);
19
+ const processor = useMemo(() => new MessageProcessor([catalog], action => onActionRef.current(action, processorRef.current?.model.getSurface(action.surfaceId))),
20
+ // Once: a host that changes what it draws with mounts a new surface.
21
+ // eslint-disable-next-line react-hooks/exhaustive-deps
22
+ []);
20
23
  const processorRef = useRef(undefined);
21
24
  processorRef.current = processor;
22
25
  const [surfaces, setSurfaces] = useState([]);
@@ -2,13 +2,26 @@
2
2
  * An application's page, drawn beside its conversation: its A2UI surface on
3
3
  * the workspace's own renderer (`InlineSurface`), fed by the chat's current
4
4
  * turn and its conversation, its buttons — and a Chat block's message, a File
5
- * upload's files — answered through the chat's controls.
5
+ * upload's files — answered through the chat's controls, what the page did
6
+ * going with the chat's run to the application's session (LOOP R-04).
7
+ *
8
+ * It draws the blocks the workspace's plugins contribute to the Canvas's
9
+ * palette (`loop.canvas.block`, R-01b) and nothing else, so that what the
10
+ * Canvas can place and what the page can draw are one list: a page that
11
+ * uses a block no enabled plugin contributes says so, in place of the page.
6
12
  *
7
13
  * @module loop/plugins/app-page/AppPage
8
14
  */
9
15
  import type { JSX } from 'react';
10
16
  import type { AppSpec } from '../../../types/agentspecs';
11
17
  import { type LoopWorkspaceContext } from '../../core';
18
+ /** The components a page's messages place, each once. */
19
+ export declare function componentsOnPage(messages: readonly unknown[]): string[];
20
+ /**
21
+ * Why a page is not drawn with the blocks contributed, or null: the blocks it
22
+ * places that no enabled plugin contributes.
23
+ */
24
+ export declare function blocksMissing(placed: readonly string[], contributed: readonly string[]): string | null;
12
25
  export type AppPageProps = {
13
26
  app: AppSpec;
14
27
  workspace: LoopWorkspaceContext;
@@ -3,17 +3,60 @@ import { useCallback, useMemo, useRef, useState } from 'react';
3
3
  import { Box } from '@datalayer/primer-addons';
4
4
  import { signal } from '@datalayer/reactor';
5
5
  import { useContributions, useSignalValue } from '@datalayer/reactor/react';
6
- import { LoopChatTurn, } from '../../core';
6
+ import { catalogOfBlocks } from '../../../components/a2ui';
7
+ import { LoopCanvasBlock, LoopChatTurn, } from '../../core';
7
8
  import { InlineSurface, SURFACE_CATALOG_ID, } from '../a2ui-surface/InlineSurface';
8
9
  import { appPageAction, appPageData, appPageMessages } from './appPageModel';
9
10
  /** No chat in the workspace: the page reads a turn that never starts. */
10
11
  const NO_TURN = signal({ id: 0, status: 'idle' });
11
12
  const NO_CONVERSATION = signal([]);
13
+ /** The components a page's messages place, each once. */
14
+ export function componentsOnPage(messages) {
15
+ const names = new Set();
16
+ for (const message of messages) {
17
+ const update = message.updateComponents;
18
+ for (const node of update?.components ?? []) {
19
+ names.add(String(node.component));
20
+ }
21
+ }
22
+ return [...names];
23
+ }
24
+ /**
25
+ * Why a page is not drawn with the blocks contributed, or null: the blocks it
26
+ * places that no enabled plugin contributes.
27
+ */
28
+ export function blocksMissing(placed, contributed) {
29
+ const have = new Set(contributed);
30
+ const missing = placed.filter(name => !have.has(name));
31
+ if (missing.length === 0) {
32
+ return null;
33
+ }
34
+ return `This page places ${missing.join(', ')}, which no enabled plugin contributes: ${missing.length === 1 ? 'it is' : 'they are'} drawn once the plugin that contributes ${missing.length === 1 ? 'it' : 'them'} is on.`;
35
+ }
12
36
  export function AppPage({ app, workspace }) {
13
37
  const entries = useContributions(LoopChatTurn);
14
38
  const turn = useSignalValue(entries[0]?.value.turn ?? NO_TURN);
15
39
  const conversation = useSignalValue(entries[0]?.value.conversation ?? NO_CONVERSATION);
16
40
  const messages = useMemo(() => appPageMessages(app, SURFACE_CATALOG_ID), [app]);
41
+ // The blocks the enabled plugins contribute: what this page may draw.
42
+ const blocks = useContributions(LoopCanvasBlock);
43
+ const contributed = [...new Set(blocks.map(entry => entry.value.id))].sort();
44
+ const drawnWith = contributed.join(',');
45
+ const drawing = useMemo(() => {
46
+ const missing = blocksMissing(componentsOnPage(messages), contributed);
47
+ if (missing) {
48
+ return { problem: missing };
49
+ }
50
+ try {
51
+ return { catalog: catalogOfBlocks(contributed) };
52
+ }
53
+ catch (error) {
54
+ return {
55
+ problem: error instanceof Error ? error.message : String(error),
56
+ };
57
+ }
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
+ }, [messages, drawnWith]);
17
60
  const data = useMemo(() => appPageData(app, turn, conversation), [app, turn, conversation]);
18
61
  const [refusal, setRefusal] = useState(null);
19
62
  // The workspace changes as the chat reports itself; the handler reads the latest.
@@ -36,10 +79,20 @@ export function AppPage({ app, workspace }) {
36
79
  controls.newChat?.();
37
80
  }
38
81
  else {
39
- // The chat's own send, as a surface in the transcript submits; the
40
- // prompt channel when the chat has not reported itself yet.
82
+ // The chat's own send, with what the page did besides — the block's
83
+ // action, its files, the settings — for the application's session
84
+ // (R-04); the prompt channel when the chat has not reported itself
85
+ // yet, which carries words only.
41
86
  if (controls.send) {
42
- controls.send(outcome.send);
87
+ const refused = controls.send(outcome.send, { loop: outcome.loop });
88
+ if (refused) {
89
+ setRefusal(refused);
90
+ return;
91
+ }
92
+ }
93
+ else if (outcome.loop.files) {
94
+ setRefusal('The conversation is not ready for a file yet: try again.');
95
+ return;
43
96
  }
44
97
  else {
45
98
  workspaceRef.current.prompts.submit(outcome.send);
@@ -50,6 +103,8 @@ export function AppPage({ app, workspace }) {
50
103
  }
51
104
  }
52
105
  }, [app]);
53
- return (_jsx(Box, { "data-testid": "app-page", sx: { height: '100%', minHeight: 0, overflow: 'auto', p: 3 }, children: _jsx(InlineSurface, { messages: messages, data: data, onAction: onAction, validationError: refusal }) }));
106
+ return (_jsx(Box, { "data-testid": "app-page", sx: { height: '100%', minHeight: 0, overflow: 'auto', p: 3 }, children: 'problem' in drawing ? (_jsx(Box, { role: "status", sx: { color: 'fg.muted', fontSize: 1 }, children: drawing.problem })) : (_jsx(InlineSurface
107
+ // A new list of blocks is a new surface, drawn with it.
108
+ , { messages: messages, data: data, onAction: onAction, validationError: refusal, catalog: drawing.catalog }, drawnWith)) }));
54
109
  }
55
110
  export default AppPage;
@@ -10,9 +10,11 @@
10
10
  * data model at a path the application takes, and what a button does reaches
11
11
  * the application through the chat's own channels: a message sent, the
12
12
  * answer stopped, the conversation started over. Nothing here is a server
13
- * API; a setting reaches the application in the words of the message it is
14
- * sent with, until the session API carries a settings change (R-04), and so
15
- * does a text file given to a widget, its contents in the message (E-01).
13
+ * API: what a button did besides its message — the block's action, the files
14
+ * given, the settings — goes with the chat's own run to the application's
15
+ * session (LOOP R-04, `forwardedProps.loop`), which puts a file where the
16
+ * application reads it; a setting also reaches a written application in the
17
+ * words of its message.
16
18
  *
17
19
  * A decision is not here: its page is drawn by the Studio's run page, with
18
20
  * its own paths (`INTERFACE_PATHS`), until it moves onto the workspace (R-02).
@@ -108,21 +110,50 @@ export declare function appPageInitialData(app: Pick<AppSpec, 'kind' | 'name' |
108
110
  */
109
111
  export declare function inputsInWords(app: Pick<AppSpec, 'interface'>, inputs: unknown): string;
110
112
  /**
111
- * The most of a file's text a message carries: about thirty thousand
112
- * tokens. A larger file is refused in a sentence rather than sent for the
113
- * model to refuse.
113
+ * The largest file a session takes: what the session API refuses above
114
+ * (`agent_runtimes.loop.apps.sessions.MAX_FILE_BYTES`), said here before
115
+ * the file is sent.
114
116
  */
115
- export declare const MAX_FILE_CHARACTERS = 120000;
117
+ export declare const MAX_FILE_BYTES: number;
118
+ /** A file as File upload gives it, and as the session API takes it. */
119
+ export type GivenFile = {
120
+ name: string;
121
+ type: string;
122
+ size: number;
123
+ data_url: string;
124
+ };
116
125
  /**
117
- * The files given on the page with their contents, for the message a widget
118
- * is run with — or why one cannot go: it is not a file as File upload gives
119
- * it, not text, or too long for a message.
126
+ * The files given on the page, as the session API takes them — or why they
127
+ * cannot go: what was given is not files as File upload gives them, or one
128
+ * is larger than a session takes. What becomes of each — put in the
129
+ * application's sandbox, or its text in the message — is the session's to
130
+ * decide (LOOP R-04).
120
131
  */
121
- export declare function filesInWords(files: unknown): {
122
- words: string;
132
+ export declare function givenFiles(files: unknown): {
133
+ files: GivenFile[];
123
134
  } | {
124
135
  refused: string;
125
136
  };
137
+ /**
138
+ * The application's settings as the page holds them, as the session API
139
+ * takes them: a choice as the option chosen, a number as a number, a toggle
140
+ * as on or off — each setting the page has a value for.
141
+ */
142
+ export declare function settingsOf(app: Pick<AppSpec, 'interface'>, inputs: unknown): Record<string, unknown>;
143
+ /**
144
+ * What the page did besides its message, for the session API: the action of
145
+ * the block pressed, the files given with it, the settings it holds. Sent
146
+ * with the chat's own run (AG-UI's `forwardedProps.loop`), so that what it
147
+ * answers streams in the conversation (LOOP R-04).
148
+ */
149
+ export type AppPageLoop = {
150
+ action: {
151
+ name: string;
152
+ payload: Record<string, unknown>;
153
+ };
154
+ files?: GivenFile[];
155
+ settings?: Record<string, unknown>;
156
+ };
126
157
  /** What a button asked, as the surface hands it over. */
127
158
  export type AppPageEvent = {
128
159
  name: string;
@@ -132,6 +163,8 @@ export type AppPageEvent = {
132
163
  export type AppPageOutcome = {
133
164
  /** A message for the application, sent as the person's next turn. */
134
165
  send: string;
166
+ /** What the page did besides, for the session it is sent in. */
167
+ loop: AppPageLoop;
135
168
  /** The inputs in words as sent, to tell a change next time. */
136
169
  inputs: string;
137
170
  /** Paths to empty once it is sent. */
@@ -36,7 +36,7 @@ const MESSAGES = {
36
36
  };
37
37
  const FILES = {
38
38
  path: '/files',
39
- meaning: 'The files given on the page, each {name, type, size, data_url}: a text file’s contents go with the next run.',
39
+ meaning: 'The files given on the page, each {name, type, size, data_url}: they go to the application’s session with the next run, put in its sandbox when it has a shell, a text file in the message when it has none.',
40
40
  words: 'The files given',
41
41
  list: true,
42
42
  };
@@ -96,7 +96,7 @@ export const APP_KIND_PATHS = {
96
96
  actions: [
97
97
  {
98
98
  name: 'run',
99
- meaning: 'Run it on its inputs: every value written under /inputs is sent, and each text file at /files, with the context’s message when there is one.',
99
+ meaning: 'Run it on its inputs: every value written under /inputs is sent, and each file at /files, with the context’s message when there is one.',
100
100
  words: 'Run it on its inputs',
101
101
  context: ['message'],
102
102
  asks: { message: 'What it says before the inputs' },
@@ -301,81 +301,85 @@ export function inputsInWords(app, inputs) {
301
301
  return lines.filter(Boolean).join('\n');
302
302
  }
303
303
  /**
304
- * The most of a file's text a message carries: about thirty thousand
305
- * tokens. A larger file is refused in a sentence rather than sent for the
306
- * model to refuse.
304
+ * The largest file a session takes: what the session API refuses above
305
+ * (`agent_runtimes.loop.apps.sessions.MAX_FILE_BYTES`), said here before
306
+ * the file is sent.
307
307
  */
308
- export const MAX_FILE_CHARACTERS = 120_000;
309
- /** The kinds of file read as text, besides `text/*`. */
310
- const TEXT_TYPES = new Set([
311
- 'application/json',
312
- 'application/xml',
313
- 'application/yaml',
314
- 'application/x-yaml',
315
- 'application/csv',
316
- ]);
317
- /** The extensions read as text whatever type the browser gives. */
318
- const TEXT_EXTENSIONS = /\.(csv|tsv|txt|md|json|jsonl|xml|ya?ml|log)$/i;
319
- /** The words of a data URL, decoded as UTF-8. */
320
- function dataUrlText(dataUrl) {
321
- const comma = dataUrl.indexOf(',');
322
- if (!dataUrl.startsWith('data:') || comma < 0) {
323
- throw new Error('it is not a data URL');
324
- }
325
- const body = dataUrl.slice(comma + 1);
326
- if (!dataUrl.slice(0, comma).endsWith(';base64')) {
327
- return decodeURIComponent(body);
328
- }
329
- const bytes = Uint8Array.from(atob(body), char => char.charCodeAt(0));
330
- return new TextDecoder('utf-8').decode(bytes);
331
- }
308
+ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
332
309
  /**
333
- * The files given on the page with their contents, for the message a widget
334
- * is run with — or why one cannot go: it is not a file as File upload gives
335
- * it, not text, or too long for a message.
310
+ * The files given on the page, as the session API takes them — or why they
311
+ * cannot go: what was given is not files as File upload gives them, or one
312
+ * is larger than a session takes. What becomes of each — put in the
313
+ * application's sandbox, or its text in the message — is the session's to
314
+ * decide (LOOP R-04).
336
315
  */
337
- export function filesInWords(files) {
316
+ export function givenFiles(files) {
338
317
  if (files === undefined || files === null) {
339
- return { words: '' };
318
+ return { files: [] };
340
319
  }
341
320
  if (!Array.isArray(files)) {
342
321
  return { refused: 'What was given as files is not a list of files.' };
343
322
  }
344
- const parts = [];
323
+ const given = [];
345
324
  for (const [index, file] of files.entries()) {
346
- const given = (file ?? {});
347
- if (typeof given.name !== 'string' || typeof given.data_url !== 'string') {
325
+ const item = (file ?? {});
326
+ if (typeof item.name !== 'string' || typeof item.data_url !== 'string') {
348
327
  return {
349
328
  refused: `File ${index + 1} is not a file as File upload gives it ({name, type, size, data_url}).`,
350
329
  };
351
330
  }
352
- const name = given.name;
353
- const type = typeof given.type === 'string' ? given.type : '';
354
- if (!type.startsWith('text/') &&
355
- !TEXT_TYPES.has(type) &&
356
- !TEXT_EXTENSIONS.test(name)) {
331
+ const size = typeof item.size === 'number' ? item.size : 0;
332
+ if (size > MAX_FILE_BYTES) {
357
333
  return {
358
- refused: `${name} is not a text file: the page hands its application a file's text, in its message.`,
334
+ refused: `${item.name} is ${size.toLocaleString('en')} bytes: a session takes files of at most ${MAX_FILE_BYTES.toLocaleString('en')}.`,
359
335
  };
360
336
  }
361
- let text;
362
- try {
363
- text = dataUrlText(given.data_url);
364
- }
365
- catch (error) {
366
- return {
367
- refused: `${name} could not be read: ${error.message}.`,
368
- };
337
+ given.push({
338
+ name: item.name,
339
+ type: typeof item.type === 'string' ? item.type : '',
340
+ size,
341
+ data_url: item.data_url,
342
+ });
343
+ }
344
+ return { files: given };
345
+ }
346
+ /**
347
+ * The application's settings as the page holds them, as the session API
348
+ * takes them: a choice as the option chosen, a number as a number, a toggle
349
+ * as on or off — each setting the page has a value for.
350
+ */
351
+ export function settingsOf(app, inputs) {
352
+ const values = inputs && typeof inputs === 'object'
353
+ ? inputs
354
+ : {};
355
+ const settings = {};
356
+ for (const setting of app.interface.settings) {
357
+ const value = values[setting.id];
358
+ if (value === undefined || value === null) {
359
+ continue;
369
360
  }
370
- if (text.length > MAX_FILE_CHARACTERS) {
371
- return {
372
- refused: `${name} is too long to send in a message: ${text.length.toLocaleString('en')} characters, at most ${MAX_FILE_CHARACTERS.toLocaleString('en')}.`,
373
- };
361
+ switch (setting.type) {
362
+ case 'select': {
363
+ const chosen = Array.isArray(value) ? value[0] : value;
364
+ if (chosen !== undefined && chosen !== '') {
365
+ settings[setting.id] = String(chosen);
366
+ }
367
+ break;
368
+ }
369
+ case 'toggle':
370
+ settings[setting.id] = Boolean(value);
371
+ break;
372
+ case 'slider':
373
+ case 'number':
374
+ if (value !== '' && !Number.isNaN(Number(value))) {
375
+ settings[setting.id] = Number(value);
376
+ }
377
+ break;
378
+ default:
379
+ settings[setting.id] = String(value);
374
380
  }
375
- const fence = text.includes('```') ? '~~~~' : '```';
376
- parts.push(`The file ${name} (${type || 'text'}):\n${fence}\n${text.replace(/\n$/, '')}\n${fence}`);
377
381
  }
378
- return { words: parts.join('\n\n') };
382
+ return settings;
379
383
  }
380
384
  /**
381
385
  * What a button pressed on the page does.
@@ -397,6 +401,13 @@ export function appPageAction(app, event, read, lastInputs = '') {
397
401
  const given = event.context?.message;
398
402
  const message = typeof given === 'string' ? given.trim() : '';
399
403
  const inputs = paths.inputs ? inputsInWords(app, read('/inputs')) : '';
404
+ const settings = paths.inputs ? settingsOf(app, read('/inputs')) : undefined;
405
+ const payload = Object.fromEntries(Object.entries(event.context ?? {}).filter(([key]) => key !== 'message' && key !== 'files'));
406
+ const loop = (files) => ({
407
+ action: { name: event.name, payload },
408
+ ...(files && files.length > 0 ? { files } : {}),
409
+ ...(settings ? { settings } : {}),
410
+ });
400
411
  switch (event.name) {
401
412
  case 'stop':
402
413
  return { stop: true };
@@ -404,21 +415,21 @@ export function appPageAction(app, event, read, lastInputs = '') {
404
415
  return { newChat: true };
405
416
  case 'run':
406
417
  case 'upload': {
407
- const given = event.context?.files;
408
- const files = filesInWords(event.name === 'upload' && given !== undefined
409
- ? given
418
+ const context = event.context?.files;
419
+ const files = givenFiles(event.name === 'upload' && context !== undefined
420
+ ? context
410
421
  : read(FILES.path));
411
422
  if ('refused' in files) {
412
423
  return files;
413
424
  }
414
- if (event.name === 'upload' && !files.words) {
425
+ if (event.name === 'upload' && files.files.length === 0) {
415
426
  return { refused: 'No file was given: choose one first.' };
416
427
  }
417
- const text = [[message, inputs].filter(Boolean).join('\n'), files.words]
418
- .filter(Boolean)
419
- .join('\n\n');
428
+ const names = files.files.map(file => file.name).join(', ');
429
+ const text = [message, inputs].filter(Boolean).join('\n') ||
430
+ (names ? `Run on ${names}.` : '');
420
431
  return text
421
- ? { send: text, inputs, clear: [] }
432
+ ? { send: text, inputs, clear: [], loop: loop(files.files) }
422
433
  : { refused: `${app.name} has no inputs to run on: set one first.` };
423
434
  }
424
435
  default: {
@@ -432,6 +443,7 @@ export function appPageAction(app, event, read, lastInputs = '') {
432
443
  send: changed ? `${written}\n\n${inputs}` : written,
433
444
  inputs,
434
445
  clear: message ? [] : [DRAFT.path],
446
+ loop: loop(),
435
447
  };
436
448
  }
437
449
  }
@@ -1,8 +1,44 @@
1
+ /**
2
+ * `@datalayer/loop-plugin-app-page` — an application's page, as a plugin
3
+ * (LOOP R-01, R-01b).
4
+ *
5
+ * A chat, a widget or a worker runs as its conversation; its page — its
6
+ * A2UI surface, or the default page of its kind when none is composed — is
7
+ * contributed as the editor beside the chat (`LoopEditorView`), the place a
8
+ * notebook or a document takes in other workspaces. `AppRenderer` mounts it
9
+ * for an application that has one (`hasAppPage`) and opens the chat on it.
10
+ *
11
+ * What the page shows and takes, by kind, is `APP_KIND_PATHS`.
12
+ *
13
+ * A page the workspace does not draw itself — a decision's, whose page is
14
+ * its host's (the Studio's run page, its hosted address, its embed) — is
15
+ * contributed by the host through `defineAppHostPagePlugin`, as the
16
+ * workspace's one view (`LoopViewType`), in a workspace without a
17
+ * conversation (LOOP R-02).
18
+ *
19
+ * @module loop/plugins/app-page
20
+ */
21
+ import type { ComponentType } from 'react';
1
22
  import { type ReactorPlugin } from '@datalayer/reactor';
2
23
  import type { AppSpec } from '../../../types/agentspecs';
24
+ import { type LoopWorkspaceContext } from '../../core';
3
25
  export declare const APP_PAGE_PLUGIN_NAME = "@datalayer/loop-plugin-app-page";
4
26
  /** The plugin that draws an application's page beside its conversation. */
5
27
  export declare function defineAppPagePlugin(app: AppSpec): ReactorPlugin<Record<string, never>, unknown, unknown>;
28
+ /** The view an application's page is, when its host draws it. */
29
+ export declare const APP_HOST_PAGE_VIEW = "app-page";
30
+ /** What a host's page is given: the application, and the workspace it is the view of. */
31
+ export type AppHostPageProps = {
32
+ app: AppSpec;
33
+ workspace: LoopWorkspaceContext;
34
+ };
35
+ /**
36
+ * The plugin that makes a host's page the workspace's view (LOOP R-02): the
37
+ * page a host draws for an application whose kind the workspace has no page
38
+ * for — a decision's. The workspace opens on it; it is ordered before every
39
+ * other view.
40
+ */
41
+ export declare function defineAppHostPagePlugin(app: AppSpec, Page: ComponentType<AppHostPageProps>): ReactorPlugin<Record<string, never>, unknown, unknown>;
6
42
  export { AppPage, type AppPageProps } from './AppPage';
7
43
  export * from './appPageModel';
8
44
  export default defineAppPagePlugin;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { BrowserIcon } from '@primer/octicons-react';
3
3
  import { contribution, definePlugin, } from '@datalayer/reactor';
4
- import { LoopEditorView } from '../../core';
4
+ import { LoopEditorView, LoopViewType, } from '../../core';
5
5
  import { AppPage } from './AppPage';
6
6
  import { APP_PAGE_SURFACE } from './appPageModel';
7
7
  export const APP_PAGE_PLUGIN_NAME = '@datalayer/loop-plugin-app-page';
@@ -29,6 +29,37 @@ export function defineAppPagePlugin(app) {
29
29
  });
30
30
  return plugin;
31
31
  }
32
+ /** The view an application's page is, when its host draws it. */
33
+ export const APP_HOST_PAGE_VIEW = 'app-page';
34
+ /**
35
+ * The plugin that makes a host's page the workspace's view (LOOP R-02): the
36
+ * page a host draws for an application whose kind the workspace has no page
37
+ * for — a decision's. The workspace opens on it; it is ordered before every
38
+ * other view.
39
+ */
40
+ export function defineAppHostPagePlugin(app, Page) {
41
+ // One component per plugin: the view host keeps it, and the application with it.
42
+ function View({ workspace, }) {
43
+ return _jsx(Page, { app: app, workspace: workspace });
44
+ }
45
+ const plugin = definePlugin({
46
+ name: `${APP_PAGE_PLUGIN_NAME}-${app.id}`,
47
+ displayName: `${app.name}: its page`,
48
+ description: `The page of ${app.name}, drawn by its host.`,
49
+ octicon: 'browser',
50
+ emoji: app.emoji || undefined,
51
+ contributes: [
52
+ contribution(LoopViewType, {
53
+ viewType: APP_HOST_PAGE_VIEW,
54
+ title: app.name,
55
+ icon: BrowserIcon,
56
+ order: -10,
57
+ load: async () => ({ default: View }),
58
+ }, { id: APP_HOST_PAGE_VIEW, order: -10 }),
59
+ ],
60
+ });
61
+ return plugin;
62
+ }
32
63
  export { AppPage } from './AppPage';
33
64
  export * from './appPageModel';
34
65
  export default defineAppPagePlugin;
@@ -326,21 +326,32 @@ export default function ChatView({ workspace }) {
326
326
  feed.assistant(reply);
327
327
  }, []);
328
328
  const heldForAdapter = useRef([]);
329
+ /** Whether the agent is turned in this page: read by `sendNow`, set below. */
330
+ const inPageRef = useRef(false);
329
331
  /*
330
332
  * Every message that goes to the agent goes through here: the composer's
331
333
  * (through the workspace's dispatch and the prompt channel), a host's, and
332
334
  * one sent from the application's page (`viewControls.send`) — so each
333
335
  * begins its turn, and a page reading the turn sees it asked and answered.
336
+ *
337
+ * What the page did besides the message goes with it as AG-UI's
338
+ * `forwardedProps` (`loop`, LOOP R-04): the application's session takes a
339
+ * file there. An agent turned in this page has no session, and no file is
340
+ * handed to it: said, rather than dropped.
334
341
  */
335
- const sendNow = useCallback((message) => {
342
+ const sendNow = useCallback((message, forwardedProps) => {
343
+ const loop = forwardedProps?.loop;
344
+ if (inPageRef.current && loop?.files && loop.files.length > 0) {
345
+ return 'A file is given to an application running on a runtime: run it on Datalayer or on your machine to give it one.';
346
+ }
336
347
  // A new turn: whatever the panel showed is gone, this message is it.
337
348
  turnFeedRef.current?.begin(message, controlsRef.current?.thread());
338
349
  const send = controlsRef.current?.send;
339
350
  if (!send) {
340
- heldForAdapter.current.push(message);
351
+ heldForAdapter.current.push([message, forwardedProps]);
341
352
  return;
342
353
  }
343
- send(message);
354
+ send(message, forwardedProps);
344
355
  }, []);
345
356
  // One writer, so neither fact can erase the other.
346
357
  useEffect(() => {
@@ -374,8 +385,8 @@ export default function ChatView({ workspace }) {
374
385
  return;
375
386
  }
376
387
  const held = heldForAdapter.current.splice(0, heldForAdapter.current.length);
377
- for (const message of held) {
378
- controlsRef.current?.send(message);
388
+ for (const [message, forwardedProps] of held) {
389
+ controlsRef.current?.send(message, forwardedProps);
379
390
  }
380
391
  }, [sendReady]);
381
392
  /*
@@ -629,6 +640,7 @@ export default function ChatView({ workspace }) {
629
640
  * for — so the location wins, which is the same rule the examples follow.
630
641
  */
631
642
  const inPage = targetRunsAgentInPage(workspace.sandbox.target ?? 'local');
643
+ inPageRef.current = inPage;
632
644
  /*
633
645
  * Who is being addressed, when this workspace runs a team.
634
646
  *
@@ -1088,6 +1100,12 @@ export default function ChatView({ workspace }) {
1088
1100
  // a new identity would resend the last suggestion.
1089
1101
  // eslint-disable-next-line react-hooks/exhaustive-deps
1090
1102
  }, [suggestion]);
1103
+ /*
1104
+ * An application's session checks who is calling (LOOP R-32, R-04): its
1105
+ * chat says so with the person's token. The embed hands its own.
1106
+ */
1107
+ const runsApp = Boolean(blueprintTurn?.createPayload?.app_spec);
1108
+ const iamToken = useIAMStore(state => state.token);
1091
1109
  const protocol = useMemo(() => inPage
1092
1110
  ? browserProtocolConfig({
1093
1111
  agentId: member?.specId ?? agentId,
@@ -1116,7 +1134,14 @@ export default function ChatView({ workspace }) {
1116
1134
  })
1117
1135
  : {
1118
1136
  type: 'ag-ui',
1119
- endpoint: `${agentServerUrl}/api/v1/ag-ui/${agentId}/`,
1137
+ // An application's agent is spoken to through its session API
1138
+ // (LOOP R-04): each thread a session, recorded under its uid,
1139
+ // run in the name the application runs in, and taking what the
1140
+ // page did besides the message — the same AG-UI events back.
1141
+ endpoint: runsApp
1142
+ ? `${agentServerUrl}/api/v1/apps/agents/${encodeURIComponent(agentId)}/ag-ui/`
1143
+ : `${agentServerUrl}/api/v1/ag-ui/${agentId}/`,
1144
+ ...(runsApp && iamToken ? { authToken: iamToken } : {}),
1120
1145
  agentId,
1121
1146
  // `/api/v1/configure`, not `/api/v1/configure/config`: the hooks
1122
1147
  // strip one trailing `config`/`configure` segment to find the API
@@ -1134,6 +1159,8 @@ export default function ChatView({ workspace }) {
1134
1159
  blueprintTurn,
1135
1160
  activeModel,
1136
1161
  agentServerUrl,
1162
+ runsApp,
1163
+ iamToken,
1137
1164
  ]);
1138
1165
  /*
1139
1166
  * The model catalogue, and the builtin tools and skills the agent reports.
@@ -173,6 +173,17 @@ export type LoopPresetOptions = {
173
173
  * cells and documents.
174
174
  */
175
175
  editors?: boolean;
176
+ /**
177
+ * Whether the workspace holds a conversation at all.
178
+ *
179
+ * True by default. A host whose one view is a page of its own — an
180
+ * application that answers in its page and has no agent to talk to, a
181
+ * decision (LOOP R-02) — passes `false`: the chat and its composer, the
182
+ * agents, the models, the editors and the page layout all stay out, so
183
+ * that nothing is launched or fetched for a conversation nobody has, and
184
+ * the workspace opens on the view a host's plugin contributes.
185
+ */
186
+ conversation?: boolean;
176
187
  /**
177
188
  * The editor choice in the workspace header rather than above the chat.
178
189
  *
@@ -52,8 +52,23 @@ import { LoopPageLayoutPlugin } from './plugins/page-layout';
52
52
  * be installed or signed into, and the switches a demonstration wants are off.
53
53
  */
54
54
  export function loopPlugins(options = {}) {
55
- const { serverUrl, target = 'browser', defaultEditor: askedEditor, showViewSelector = true, hideChatHeader = false, chatHeaderButtons = false, promptPlacement, autoFocusPrompt = true, suggestionLabels = true, fullScreenTopOffset = 0, showAgentVariants = false, agentSummary = true, teamId, teamPicker = true, pageLayout = false, pageLayoutArrangement = 'page', pageLayoutTurnPanel = 'below', pageLayoutPrompt = 'docked', pageLayoutPromptAnchor = 'top', pageLayoutSize, pageLayoutTurnPanelFooter = 'full', localAgent, localAgentSpec, datalayerAgentSpecId, datalayerCreatePayload, targetFixed, themeVariant, themeOverrides, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, graph = false, commandPalette = false, pluginsPanel = false, windowFrame = false, firstPromptHook, } = options;
55
+ const { serverUrl, target = 'browser', defaultEditor: askedEditor, showViewSelector = true, hideChatHeader = false, chatHeaderButtons = false, promptPlacement, autoFocusPrompt = true, suggestionLabels = true, fullScreenTopOffset = 0, showAgentVariants = false, agentSummary = true, teamId, teamPicker = true, pageLayout = false, pageLayoutArrangement = 'page', pageLayoutTurnPanel = 'below', pageLayoutPrompt = 'docked', pageLayoutPromptAnchor = 'top', pageLayoutSize, pageLayoutTurnPanelFooter = 'full', localAgent, localAgentSpec, datalayerAgentSpecId, datalayerCreatePayload, targetFixed, themeVariant, themeOverrides, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, conversation = true, graph = false, commandPalette = false, pluginsPanel = false, windowFrame = false, firstPromptHook, } = options;
56
56
  const defaultEditor = askedEditor ?? (editors ? 'notebook' : 'none');
57
+ // A workspace without a conversation: the theme, the shell's points, and
58
+ // the host's chrome; its view is a host's plugin's.
59
+ if (!conversation) {
60
+ return [
61
+ ThemePlugin,
62
+ configurePlugin(ShellPlugin, {
63
+ defaultEditor: 'none',
64
+ showSelector: false,
65
+ }),
66
+ ...(graph ? [GraphViewPlugin] : []),
67
+ ...(commandPalette ? [LoopCommandsPlugin] : []),
68
+ ...(pluginsPanel ? [PluginsPanelPlugin] : []),
69
+ ...(windowFrame ? [WindowFramePlugin] : []),
70
+ ];
71
+ }
57
72
  return [
58
73
  // The chat owns the editor beside it, so which one opens is its
59
74
  // configuration rather than the workspace's.
@@ -83,6 +83,8 @@ export declare class AGUIAdapter extends BaseProtocolAdapter {
83
83
  provider: string;
84
84
  accessToken: string;
85
85
  }>;
86
+ /** What goes with the run besides the conversation (AG-UI's). */
87
+ forwardedProps?: Record<string, unknown>;
86
88
  }): Promise<void>;
87
89
  /**
88
90
  * Send tool result back through AG-UI and continue the conversation.
@@ -191,7 +191,7 @@ export class AGUIAdapter extends BaseProtocolAdapter {
191
191
  skills: options.skills,
192
192
  }),
193
193
  context: [],
194
- forwardedProps: null,
194
+ forwardedProps: options?.forwardedProps ?? null,
195
195
  // Include model for per-request model override
196
196
  ...(options?.model && { model: options.model }),
197
197
  // Include identities for tool execution with OAuth tokens
@@ -654,7 +654,8 @@ export interface ChatBaseProps {
654
654
  * Called again with `null` when the chat can no longer send.
655
655
  */
656
656
  onSendReady?: (controls: {
657
- send: (message: string) => void;
657
+ /** Send a message; `forwardedProps` go with its run (AG-UI's). */
658
+ send: (message: string, forwardedProps?: Record<string, unknown>) => void;
658
659
  stop: () => void;
659
660
  /** Start the conversation over — what the header's + does. */
660
661
  newChat: () => void;
@@ -142,6 +142,8 @@ export interface ProtocolAdapter {
142
142
  provider: string;
143
143
  accessToken: string;
144
144
  }>;
145
+ /** What goes with the run besides the conversation (AG-UI's `forwardedProps`). */
146
+ forwardedProps?: Record<string, unknown>;
145
147
  }): Promise<void>;
146
148
  /**
147
149
  * Send tool execution result back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.38",
3
+ "version": "1.3.40",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",