@datalayer/agent-runtimes 1.3.52 → 1.3.54

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.
Files changed (34) hide show
  1. package/lib/chat/ChatFloating.d.ts +1 -1
  2. package/lib/chat/ChatFloating.js +33 -6
  3. package/lib/chat/assistant/AssistantStage.d.ts +4 -2
  4. package/lib/chat/assistant/AssistantStage.js +16 -1
  5. package/lib/chat/assistant/SpeechBalloon.d.ts +6 -2
  6. package/lib/chat/assistant/SpeechBalloon.js +4 -3
  7. package/lib/chat/assistant/formats/stateAnimations.js +3 -0
  8. package/lib/chat/assistant/state.d.ts +37 -5
  9. package/lib/chat/assistant/state.js +18 -3
  10. package/lib/chat/presence/Presence.d.ts +2 -1
  11. package/lib/chat/presence/Presence.js +11 -3
  12. package/lib/chat/presence/presenceStatus.d.ts +10 -4
  13. package/lib/chat/presence/presenceStatus.js +9 -2
  14. package/lib/loop/apps/AppRenderer.d.ts +6 -1
  15. package/lib/loop/apps/AppRenderer.js +2 -1
  16. package/lib/loop/apps/visitorToken.d.ts +35 -0
  17. package/lib/loop/apps/visitorToken.js +114 -0
  18. package/lib/loop/core/index.d.ts +8 -0
  19. package/lib/loop/embed/LoopEmbed.js +2 -0
  20. package/lib/loop/plugins/agents/DatalayerAgentBridge.js +15 -3
  21. package/lib/loop/plugins/agents/plugin.d.ts +24 -0
  22. package/lib/loop/plugins/agents/plugin.js +0 -17
  23. package/lib/loop/plugins/assistant/index.d.ts +19 -9
  24. package/lib/loop/plugins/assistant/index.js +67 -25
  25. package/lib/loop/plugins/chat/ChatView.js +14 -3
  26. package/lib/loop/plugins/chat/index.d.ts +6 -1
  27. package/lib/loop/plugins/chat/index.js +1 -0
  28. package/lib/loop/plugins/chat/turnState.d.ts +5 -0
  29. package/lib/loop/plugins/chat/turnState.js +7 -0
  30. package/lib/loop/presets.d.ts +2 -0
  31. package/lib/loop/presets.js +2 -1
  32. package/lib/stories/loop/ReferenceScreens.d.ts +1 -1
  33. package/lib/stories/loop/ReferenceScreens.js +16 -2
  34. package/package.json +2 -2
@@ -31,7 +31,7 @@ import type { ChatCommonProps, ChatViewMode, ThemeOverrides } from '../types';
31
31
  export type FloatingConversation = {
32
32
  /** What the window holds, under its header (the face, the title, close). */
33
33
  body: React.ReactNode;
34
- /** What it is doing: idle, thinking, working, waiting for the person. */
34
+ /** What it is doing: idle, thinking, working, waiting for the person, paused. */
35
35
  presence: PresenceState;
36
36
  /** Its newest words, said in the balloon until heard. */
37
37
  saying?: AssistantSaying;
@@ -33,7 +33,7 @@ import { disabledChatViewModes, resolveMountPoint } from './viewModes';
33
33
  import { AssistantStage } from './assistant/AssistantStage';
34
34
  import { SpeechBalloon } from './assistant/SpeechBalloon';
35
35
  import { DEFAULT_ASSISTANT_CHARACTER, } from './assistant/characters';
36
- import { assistantStateOf, latestSaying, newestIsAnswer, keepAway, keptAway, } from './assistant/state';
36
+ import { assistantStateOf, latestSaying, newestIsAnswer, keepAway, keptAway, ASSISTANT_WORDS, } from './assistant/state';
37
37
  import { presenceState, presenceToolOf, } from './presence/presenceStatus';
38
38
  import { useChatOpen, useChatMessages, useChatStore, } from '../stores/chatStore';
39
39
  import { useChatKeyboardShortcuts, getShortcutDisplay, } from '@datalayer/core/lib/hooks';
@@ -172,13 +172,40 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
172
172
  speaking: answering,
173
173
  });
174
174
  const unheard = !!saying && saying.id !== heardId;
175
- const assistantBalloon = assistantState === 'waiting'
176
- ? { text: 'Waiting for you — open the conversation to answer.' }
177
- : unheard && saying
178
- ? { text: saying.text, more: saying.more }
179
- : { text: description };
175
+ /*
176
+ * The approval it waits on, answered in the balloon (T-23) by the chat's
177
+ * own answers to its approvals — those its tool-approval banner sends.
178
+ */
179
+ const [deciding, setDeciding] = useState(false);
180
+ const firstPending = pendingApprovals?.[0];
181
+ const balloonApproval = firstPending && onApproveApproval && onRejectApproval
182
+ ? {
183
+ id: firstPending.id,
184
+ asks: firstPending.toolDescription || firstPending.toolName,
185
+ others: (pendingApprovals?.length ?? 1) - 1,
186
+ deciding,
187
+ onApprove: () => {
188
+ setDeciding(true);
189
+ void Promise.resolve(onApproveApproval(firstPending.id)).finally(() => setDeciding(false));
190
+ },
191
+ onDeny: () => {
192
+ setDeciding(true);
193
+ void Promise.resolve(onRejectApproval(firstPending.id)).finally(() => setDeciding(false));
194
+ },
195
+ }
196
+ : undefined;
197
+ const assistantBalloon = assistantState === 'paused'
198
+ ? { text: ASSISTANT_WORDS.paused }
199
+ : balloonApproval
200
+ ? { text: ASSISTANT_WORDS.approval, approval: balloonApproval }
201
+ : assistantState === 'waiting'
202
+ ? { text: ASSISTANT_WORDS.waiting }
203
+ : unheard && saying
204
+ ? { text: saying.text, more: saying.more }
205
+ : { text: description };
180
206
  const balloonInsists = assistantState === 'greeting' ||
181
207
  assistantState === 'waiting' ||
208
+ !!balloonApproval ||
182
209
  (unheard && (assistantState === 'speaking' || freshSaying));
183
210
  const panelOnLoadingChange = panelProps?.onLoadingChange;
184
211
  const panelOnDisplayItemsChange = panelProps?.onDisplayItemsChange;
@@ -17,7 +17,7 @@
17
17
  import type { JSX, RefObject } from 'react';
18
18
  import { type AssistantCharacter } from './characters';
19
19
  import type { AssistantCharacterData } from './formats/types';
20
- import { type AssistantAway, type AssistantState } from './state';
20
+ import { type AssistantAway, type AssistantState, type BalloonApproval } from './state';
21
21
  /**
22
22
  * Where the balloon goes so that it stays inside the window (T-23): toward
23
23
  * the middle of the page from whichever half the character stands in, and
@@ -69,11 +69,13 @@ export interface AssistantStageProps {
69
69
  /**
70
70
  * What the balloon says while the conversation is closed: the agent's
71
71
  * newest words, as the Office Assistant said them (T-23), or a welcome.
72
- * `more` adds *Open the conversation* for the rest.
72
+ * `more` adds *Open the conversation* for the rest; `approval`, an
73
+ * approval to answer there, with *Approve* and *Deny*.
73
74
  */
74
75
  balloon?: {
75
76
  text: string;
76
77
  more?: boolean;
78
+ approval?: BalloonApproval;
77
79
  };
78
80
  /** Show the balloon without being hovered: something new to say. */
79
81
  insist?: boolean;
@@ -41,6 +41,10 @@ const MOTIONS = {
41
41
  '0%, 100%': { transform: 'scaleY(1)' },
42
42
  '50%': { transform: 'scaleY(2.2)' },
43
43
  },
44
+ '@keyframes assistantDoze': {
45
+ '0%, 100%': { transform: 'translateY(2px) scale(1, 0.97)' },
46
+ '50%': { transform: 'translateY(3px) scale(1.02, 0.95)' },
47
+ },
44
48
  '@keyframes assistantLeave': {
45
49
  from: { transform: 'scale(1)', opacity: 1 },
46
50
  to: { transform: 'scale(0.2) translateY(20px)', opacity: 0 },
@@ -255,6 +259,17 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
255
259
  '&[data-assistant-state="speaking"] .assistant-body': {
256
260
  animation: 'assistantFloat 1.6s ease-in-out infinite',
257
261
  },
262
+ // Paused (R-17): it dozes — its eyes shut, its breath slow, a little
263
+ // greyed — and, still, it is asleep: the shut eyes are not a motion.
264
+ '&[data-assistant-state="paused"] .assistant-body': {
265
+ animation: 'assistantDoze 4.8s ease-in-out infinite',
266
+ filter: 'grayscale(0.5)',
267
+ opacity: 0.8,
268
+ },
269
+ '&[data-assistant-state="paused"] .assistant-lids ellipse': {
270
+ animation: 'none',
271
+ transform: 'scaleY(1)',
272
+ },
258
273
  '&[data-assistant-state="goodbye"] .assistant-body': {
259
274
  animation: 'assistantLeave 0.6s ease-in forwards',
260
275
  },
@@ -262,7 +277,7 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
262
277
  '@media (prefers-reduced-motion: reduce)': {
263
278
  '& *': { animation: 'none !important' },
264
279
  },
265
- }, onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [showBalloon && balloon && (_jsx(SpeechBalloon, { text: balloon.text, more: balloon.more, onOpen: onToggle, above: size + 8, side: balloonSide(place).side, align: balloonSide(place).align, tailAt: size / 2 })), _jsx(Box, { as: "button", type: "button", "aria-label": open ? `Close the conversation with ${name}` : `Talk to ${name}`, "aria-expanded": open, onPointerDown: (event) => {
280
+ }, onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [showBalloon && balloon && (_jsx(SpeechBalloon, { text: balloon.text, more: balloon.more, approval: balloon.approval, onOpen: onToggle, above: size + 8, side: balloonSide(place).side, align: balloonSide(place).align, tailAt: size / 2 })), _jsx(Box, { as: "button", type: "button", "aria-label": open ? `Close the conversation with ${name}` : `Talk to ${name}`, "aria-expanded": open, onPointerDown: (event) => {
266
281
  pressedAt.current = { x: event.clientX, y: event.clientY };
267
282
  onDragStart(event);
268
283
  }, onClick: (event) => {
@@ -2,11 +2,13 @@
2
2
  * A speech balloon over something that stands for the agent — the floating
3
3
  * assistant's character, or the floating popup's button while the chat is
4
4
  * closed: the agent's newest words, as the Office Assistant said them
5
- * (LOOP T-23), with *Open the conversation* when there is more.
5
+ * (LOOP T-23), with *Open the conversation* when there is more — and an
6
+ * approval it waits on, answered there with *Approve* or *Deny*.
6
7
  *
7
8
  * @module chat/assistant/SpeechBalloon
8
9
  */
9
10
  import type { JSX } from 'react';
11
+ import { type BalloonApproval } from './state';
10
12
  export interface SpeechBalloonProps {
11
13
  text: string;
12
14
  /** There is more than the balloon holds. */
@@ -21,6 +23,8 @@ export interface SpeechBalloonProps {
21
23
  align: 'left' | 'right';
22
24
  /** Where the tail points, from that edge, in pixels. */
23
25
  tailAt: number;
26
+ /** An approval it waits on: what is asked, and the two answers. */
27
+ approval?: BalloonApproval;
24
28
  }
25
- export declare function SpeechBalloon({ text, more, onOpen, above, side, align, tailAt, }: SpeechBalloonProps): JSX.Element;
29
+ export declare function SpeechBalloon({ text, more, onOpen, above, side, align, tailAt, approval, }: SpeechBalloonProps): JSX.Element;
26
30
  export default SpeechBalloon;
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text } from '@primer/react';
2
+ import { Button, Text } from '@primer/react';
3
3
  import { Box } from '@datalayer/primer-addons';
4
- export function SpeechBalloon({ text, more = false, onOpen, above, side = 'above', align, tailAt, }) {
4
+ import { ASSISTANT_WORDS } from './state';
5
+ export function SpeechBalloon({ text, more = false, onOpen, above, side = 'above', align, tailAt, approval, }) {
5
6
  return (_jsxs(Box, { role: "status", "aria-live": "polite", "data-speech-balloon": "", sx: {
6
7
  position: 'absolute',
7
8
  // Pixels, as strings: a number here is read as the theme's space
@@ -36,7 +37,7 @@ export function SpeechBalloon({ text, more = false, onOpen, above, side = 'above
36
37
  borderColor: 'border.default',
37
38
  transform: side === 'above' ? 'rotate(45deg)' : 'rotate(225deg)',
38
39
  },
39
- }, children: [_jsx(Text, { as: "p", sx: { m: 0 }, children: text }), more && (_jsx(Box, { as: "button", type: "button", onClick: onOpen, sx: {
40
+ }, children: [_jsx(Text, { as: "p", sx: { m: 0 }, children: text }), approval && (_jsxs(Box, { "data-balloon-approval": approval.id, sx: { mt: 1 }, children: [_jsx(Text, { as: "p", sx: { m: 0, fontWeight: 'semibold' }, children: approval.asks }), approval.why ? (_jsx(Text, { as: "p", sx: { m: 0, color: 'fg.muted', fontSize: 0 }, children: approval.why })) : null, _jsxs(Box, { sx: { display: 'flex', gap: 2, mt: 2 }, children: [_jsx(Button, { size: "small", variant: "primary", disabled: approval.deciding, onClick: approval.onApprove, children: ASSISTANT_WORDS.approve }), _jsx(Button, { size: "small", disabled: approval.deciding, onClick: approval.onDeny, children: ASSISTANT_WORDS.deny })] }), approval.others > 0 ? (_jsx(Text, { as: "p", sx: { m: 0, mt: 1, color: 'fg.muted', fontSize: 0 }, children: `${approval.others} more waiting.` })) : null] })), more && (_jsx(Box, { as: "button", type: "button", onClick: onOpen, sx: {
40
41
  mt: 1,
41
42
  p: 0,
42
43
  border: 0,
@@ -33,6 +33,7 @@ const NAMES = {
33
33
  'GestureDown',
34
34
  'Acknowledge',
35
35
  ],
36
+ paused: ['IdleSnooze', 'Sleep', 'Sleeping', 'Snooze', 'Rest', 'RestPose'],
36
37
  greeting: ['Greeting', 'Greet', 'Show', 'Wave'],
37
38
  speaking: [
38
39
  'Explain',
@@ -50,6 +51,7 @@ const AUTHORED = {
50
51
  thinking: [],
51
52
  working: [],
52
53
  waiting: ['Listening'],
54
+ paused: [],
53
55
  greeting: ['Showing'],
54
56
  speaking: ['Speaking'],
55
57
  goodbye: ['Hiding'],
@@ -60,6 +62,7 @@ const STATES = [
60
62
  'thinking',
61
63
  'working',
62
64
  'waiting',
65
+ 'paused',
63
66
  'greeting',
64
67
  'speaking',
65
68
  'goodbye',
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * What the floating assistant acts out (LOOP T-22): the presence of the
3
- * application (T-08), and three moments of its own — greeting when it
3
+ * application (T-08) — paused among them, when its owner paused it — and
4
+ * three moments of its own — greeting when it
4
5
  * arrives, speaking while an answer streams in, goodbye when it is sent away.
5
6
  *
6
7
  * @module chat/assistant/state
7
8
  */
8
9
  import type { PresenceState } from '../presence/presenceStatus';
9
10
  /** The states a character has an animation for. */
10
- export type AssistantState = 'idle' | 'thinking' | 'working' | 'waiting' | 'greeting' | 'speaking' | 'goodbye';
11
+ export type AssistantState = 'idle' | 'thinking' | 'working' | 'waiting' | 'paused' | 'greeting' | 'speaking' | 'goodbye';
11
12
  /** What a character is doing, beyond what the application is doing. */
12
13
  export interface AssistantMoment {
13
14
  /** It has just appeared, and says hello once. */
@@ -20,9 +21,10 @@ export interface AssistantMoment {
20
21
  /**
21
22
  * The state to act, from the application's presence and the moment.
22
23
  *
23
- * Leaving and arriving win, being short; then waiting for the person, which
24
- * must never be missed; then speaking over thinking, since words arriving
25
- * are what the person watches; then the presence as it is.
24
+ * Leaving wins, being short; then paused — a paused application neither
25
+ * greets nor speaks, it dozes; then arriving; then waiting for the person,
26
+ * which must never be missed; then speaking over thinking, since words
27
+ * arriving are what the person watches; then the presence as it is.
26
28
  */
27
29
  export declare function assistantStateOf(presence: PresenceState, moment?: AssistantMoment): AssistantState;
28
30
  /**
@@ -30,6 +32,36 @@ export declare function assistantStateOf(presence: PresenceState, moment?: Assis
30
32
  * assistant's own message, not a tool call. Read with `busy`, it is speaking.
31
33
  */
32
34
  export declare function newestIsAnswer(items: readonly unknown[]): boolean;
35
+ /** What the balloon says for a state, when the agent has said nothing newer. */
36
+ export declare const ASSISTANT_WORDS: {
37
+ /** An approval waits and cannot be answered in the balloon itself. */
38
+ readonly waiting: "Waiting for you — open the conversation to answer.";
39
+ /** It waits on the person's yes: Approve or Deny, in the balloon (T-23). */
40
+ readonly approval: "Waiting for you";
41
+ readonly approve: "Approve";
42
+ readonly deny: "Deny";
43
+ /** Its deployment is paused (R-17). */
44
+ readonly paused: "Paused — it answers again once it is resumed.";
45
+ };
46
+ /**
47
+ * An approval asked of the person, answered in the balloon (T-23): what is
48
+ * asked, under which rule, and the two answers, sent where every approval is
49
+ * answered (U-19).
50
+ */
51
+ export interface BalloonApproval {
52
+ /** Its id: a new one is a new question. */
53
+ id: string;
54
+ /** What it asks to do: the tool it would call. */
55
+ asks: string;
56
+ /** The rule or check it was asked under, when the runtime said. */
57
+ why?: string;
58
+ /** How many more wait after this one. */
59
+ others: number;
60
+ /** An answer is being sent. */
61
+ deciding?: boolean;
62
+ onApprove: () => void;
63
+ onDeny: () => void;
64
+ }
33
65
  /** A word from the agent, for the character's balloon. */
34
66
  export interface AssistantSaying {
35
67
  /** The message it comes from: a new id is a new thing said. */
@@ -5,14 +5,18 @@
5
5
  /**
6
6
  * The state to act, from the application's presence and the moment.
7
7
  *
8
- * Leaving and arriving win, being short; then waiting for the person, which
9
- * must never be missed; then speaking over thinking, since words arriving
10
- * are what the person watches; then the presence as it is.
8
+ * Leaving wins, being short; then paused — a paused application neither
9
+ * greets nor speaks, it dozes; then arriving; then waiting for the person,
10
+ * which must never be missed; then speaking over thinking, since words
11
+ * arriving are what the person watches; then the presence as it is.
11
12
  */
12
13
  export function assistantStateOf(presence, moment = {}) {
13
14
  if (moment.leaving) {
14
15
  return 'goodbye';
15
16
  }
17
+ if (presence === 'paused') {
18
+ return 'paused';
19
+ }
16
20
  if (moment.arriving) {
17
21
  return 'greeting';
18
22
  }
@@ -34,6 +38,17 @@ export function newestIsAnswer(items) {
34
38
  newest.role === 'assistant' &&
35
39
  typeof newest.toolName !== 'string');
36
40
  }
41
+ /** What the balloon says for a state, when the agent has said nothing newer. */
42
+ export const ASSISTANT_WORDS = {
43
+ /** An approval waits and cannot be answered in the balloon itself. */
44
+ waiting: 'Waiting for you — open the conversation to answer.',
45
+ /** It waits on the person's yes: Approve or Deny, in the balloon (T-23). */
46
+ approval: 'Waiting for you',
47
+ approve: 'Approve',
48
+ deny: 'Deny',
49
+ /** Its deployment is paused (R-17). */
50
+ paused: 'Paused — it answers again once it is resumed.',
51
+ };
37
52
  /** How much of a message the balloon says before *Open the conversation*. */
38
53
  export const SAYING_LIMIT = 220;
39
54
  /**
@@ -3,7 +3,8 @@
3
3
  * motion around it while it thinks or works, and one line saying what it is
4
4
  * doing. The face is never redrawn; the motion is a soft ring in the accent,
5
5
  * still under `prefers-reduced-motion`, and the line changes at the theme's
6
- * status pace (T-10).
6
+ * status pace (T-10). Paused, the ring is a still, muted dashed circle and
7
+ * the face is dimmed: nothing moves around something that does nothing.
7
8
  *
8
9
  * @module chat/presence/Presence
9
10
  */
@@ -13,6 +13,7 @@ export function FaceDrawing({ face, size }) {
13
13
  }
14
14
  export function PresenceFace({ face, size, state, }) {
15
15
  const active = state === 'thinking' || state === 'working';
16
+ const paused = state === 'paused';
16
17
  return (_jsxs(Box, { as: "span", "aria-hidden": true, "data-presence": state, sx: {
17
18
  position: 'relative',
18
19
  display: 'inline-flex',
@@ -27,8 +28,10 @@ export function PresenceFace({ face, size, state, }) {
27
28
  position: 'absolute',
28
29
  inset: 0,
29
30
  borderRadius: '50%',
30
- boxShadow: `0 0 0 2px ${ACCENT}`,
31
- opacity: active ? 0.9 : state === 'waiting' ? 0.9 : 0,
31
+ boxShadow: paused ? 'none' : `0 0 0 2px ${ACCENT}`,
32
+ border: paused ? '2px dashed' : 'none',
33
+ borderColor: 'border.default',
34
+ opacity: active || state === 'waiting' || paused ? 0.9 : 0,
32
35
  transition: 'opacity var(--theme-motion-status, 0ms) var(--theme-motion-easing, ease)',
33
36
  animation: active
34
37
  ? 'loopPresenceBreath 1.8s ease-in-out infinite'
@@ -40,7 +43,12 @@ export function PresenceFace({ face, size, state, }) {
40
43
  '@media (prefers-reduced-motion: reduce)': {
41
44
  animation: 'none',
42
45
  },
43
- } }), _jsx(FaceDrawing, { face: face, size: size })] }));
46
+ } }), _jsx(Box, { as: "span", sx: {
47
+ display: 'inline-flex',
48
+ opacity: paused ? 0.55 : 1,
49
+ filter: paused ? 'grayscale(0.6)' : 'none',
50
+ transition: 'opacity var(--theme-motion-status, 0ms) var(--theme-motion-easing, ease)',
51
+ }, children: _jsx(FaceDrawing, { face: face, size: size }) })] }));
44
52
  }
45
53
  export function PresenceLine({ state }) {
46
54
  return (_jsx(Text, { role: "status", "aria-live": "polite", "data-presence-line": state, sx: {
@@ -4,8 +4,11 @@
4
4
  *
5
5
  * @module chat/presence/presenceStatus
6
6
  */
7
- /** The states a person is told about. Paused waits for a pause to exist. */
8
- export type PresenceState = 'idle' | 'thinking' | 'working' | 'waiting';
7
+ /**
8
+ * The states a person is told about. Paused is its deployment's: its owner
9
+ * paused it (LOOP R-17), and it answers nobody until it is resumed.
10
+ */
11
+ export type PresenceState = 'idle' | 'thinking' | 'working' | 'waiting' | 'paused';
9
12
  /** The line each state is said by. */
10
13
  export declare const PRESENCE_LINES: Record<PresenceState, string>;
11
14
  /** The newest tool call of the conversation, as far as presence reads it. */
@@ -16,14 +19,17 @@ export interface PresenceTool {
16
19
  pendingApproval: boolean;
17
20
  }
18
21
  /**
19
- * The state, from whether a turn is running and the newest tool call.
22
+ * The state, from whether a turn is running, the newest tool call, and
23
+ * whether its deployment is paused.
20
24
  *
25
+ * Paused wins: a paused application does nothing, whatever its last turn
26
+ * left behind.
21
27
  * Waiting for an approval is said whether or not the turn is still
22
28
  * streaming — the stream may end while the approval is asked. A tool that
23
29
  * has not returned counts as work only during a turn: a stopped turn can
24
30
  * leave one open, and the application is not working then.
25
31
  */
26
- export declare function presenceState(busy: boolean, tool?: PresenceTool): PresenceState;
32
+ export declare function presenceState(busy: boolean, tool?: PresenceTool, paused?: boolean): PresenceState;
27
33
  /**
28
34
  * The newest tool call of the conversation: whether it has not returned, and
29
35
  * whether it waits on a person's approval. A tool call is an item with a
@@ -8,16 +8,23 @@ export const PRESENCE_LINES = {
8
8
  thinking: 'Thinking…',
9
9
  working: 'Working…',
10
10
  waiting: 'Waiting for you',
11
+ paused: 'Paused',
11
12
  };
12
13
  /**
13
- * The state, from whether a turn is running and the newest tool call.
14
+ * The state, from whether a turn is running, the newest tool call, and
15
+ * whether its deployment is paused.
14
16
  *
17
+ * Paused wins: a paused application does nothing, whatever its last turn
18
+ * left behind.
15
19
  * Waiting for an approval is said whether or not the turn is still
16
20
  * streaming — the stream may end while the approval is asked. A tool that
17
21
  * has not returned counts as work only during a turn: a stopped turn can
18
22
  * leave one open, and the application is not working then.
19
23
  */
20
- export function presenceState(busy, tool) {
24
+ export function presenceState(busy, tool, paused = false) {
25
+ if (paused) {
26
+ return 'paused';
27
+ }
21
28
  if (tool?.open && tool.pendingApproval) {
22
29
  return 'waiting';
23
30
  }
@@ -89,6 +89,11 @@ export type AppRendererProps = Omit<LoopEmbedProps, 'agentId'> & {
89
89
  * stable function, such as a state setter.
90
90
  */
91
91
  onPresence?: (state: PresenceState) => void;
92
+ /**
93
+ * Its deployment is paused (LOOP R-17), as its host knows it: its chat
94
+ * says *Paused* beside its face and its assistant dozes (T-08, T-22).
95
+ */
96
+ paused?: boolean;
92
97
  /**
93
98
  * Told what the application last said, and whether its newest words are
94
99
  * an answer — for a host that says them outside the conversation: the
@@ -195,5 +200,5 @@ export declare const DATALAYER_BOOTSTRAP_AGENTSPEC = "example-simple";
195
200
  * what the page around it set does not reach in.
196
201
  */
197
202
  export declare function appThemeOverrides(app: AppSpec): ThemeOverrides | undefined;
198
- export declare function AppRenderer({ app, plugins, instance, onPresence, onSaying, sidebar, frame, page, pluginsOff, ...embed }: AppRendererProps): React.JSX.Element;
203
+ export declare function AppRenderer({ app, plugins, instance, onPresence, onSaying, paused, sidebar, frame, page, pluginsOff, ...embed }: AppRendererProps): React.JSX.Element;
199
204
  export default AppRenderer;
@@ -252,7 +252,7 @@ export function appThemeOverrides(app) {
252
252
  }
253
253
  : undefined;
254
254
  }
255
- export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, onSaying, sidebar = false, frame = false, page, pluginsOff, ...embed }) {
255
+ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, onSaying, paused = false, sidebar = false, frame = false, page, pluginsOff, ...embed }) {
256
256
  /*
257
257
  * The kind's plugins, made once per application. `LoopEmbed` rebuilds its
258
258
  * whole reactor when its plugins change, so a new plugin on every render
@@ -325,6 +325,7 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
325
325
  name: app.name,
326
326
  face: app.emoji,
327
327
  welcome: app.interface.welcome || app.description,
328
+ paused,
328
329
  onPresence,
329
330
  onSaying,
330
331
  }, showTokenUsage: false, datalayerCreatePayload: datalayerCreatePayload, ...embed, plugins: allPlugins }));
@@ -0,0 +1,35 @@
1
+ /** Where a conversation without an account runs: the visitors' runtime, and what it is for. */
2
+ export type DatalayerVisitors = {
3
+ /** The visitors' runtime, as the chat addresses it. */
4
+ url: string;
5
+ /** The application the visitor's token is for: an example's id, or `at:<slug>`. */
6
+ app: string;
7
+ /** ai-inference, which mints the visitor's token. */
8
+ inferenceUrl: string;
9
+ };
10
+ /** A visitor's id: what ai-inference accepts (8 to 64 letters, digits or dashes). */
11
+ export declare function isVisitorId(value: string | null | undefined): value is string;
12
+ /** The id this tab keeps for its visitor, or `''` until ai-inference made one. */
13
+ export declare function keptVisitorId(): string;
14
+ /** A visitor's token, and when it is to be minted again. */
15
+ export type VisitorToken = {
16
+ token: string;
17
+ visitor: string;
18
+ /** Epoch milliseconds: the token's own lifetime from now. */
19
+ renewAt: number;
20
+ };
21
+ /** The body ai-inference is asked with. */
22
+ export declare function visitorTokenRequest(app: string, visitor: string): {
23
+ app: string;
24
+ visitor?: string;
25
+ };
26
+ /** Mint a visitor's token for an application; throws with ai-inference's sentence. */
27
+ export declare function fetchVisitorToken(inferenceUrl: string, app: string, visitor?: string, fetcher?: typeof fetch): Promise<VisitorToken>;
28
+ /**
29
+ * The visitor's token for an application while `visitors` is set: minted at
30
+ * once and again before it runs out. `error` says why none could be had.
31
+ */
32
+ export declare function useVisitorToken(visitors: DatalayerVisitors | undefined): {
33
+ token?: string;
34
+ error?: string;
35
+ };
@@ -0,0 +1,114 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A visitor's token, for a conversation without an account on the visitors'
7
+ * runtime (LOOP R-30).
8
+ *
9
+ * ai-inference mints it (`POST /anonymous/token`) naming the visitor — the id
10
+ * this tab keeps for them, so that a new token is the same visitor, whose
11
+ * session, turns and rate it does not reset — and the one application it is
12
+ * for: an example's id, or `at:<slug>` for an application at its address.
13
+ * It lives minutes, so it is minted again before it runs out: what limits a
14
+ * visitor is on the servers (turns a day, a session's minutes, a rate and a
15
+ * ceiling for the day), not the life of a key.
16
+ *
17
+ * @module loop/apps/visitorToken
18
+ */
19
+ import { useEffect, useState } from 'react';
20
+ /** The id this tab keeps for its visitor. */
21
+ const VISITOR_KEY = 'datalayer-loop-visitor';
22
+ /** Minted again this long before the token runs out. */
23
+ const MARGIN_MS = 30_000;
24
+ /** A visitor's id: what ai-inference accepts (8 to 64 letters, digits or dashes). */
25
+ export function isVisitorId(value) {
26
+ return typeof value === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(value);
27
+ }
28
+ /** The id this tab keeps for its visitor, or `''` until ai-inference made one. */
29
+ export function keptVisitorId() {
30
+ try {
31
+ const kept = window.sessionStorage.getItem(VISITOR_KEY);
32
+ return isVisitorId(kept) ? kept : '';
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ function keepVisitorId(visitor) {
39
+ try {
40
+ window.sessionStorage.setItem(VISITOR_KEY, visitor);
41
+ }
42
+ catch {
43
+ // A tab that keeps nothing is a new visitor at each token.
44
+ }
45
+ }
46
+ /** The body ai-inference is asked with. */
47
+ export function visitorTokenRequest(app, visitor) {
48
+ return isVisitorId(visitor) ? { app, visitor } : { app };
49
+ }
50
+ /** Mint a visitor's token for an application; throws with ai-inference's sentence. */
51
+ export async function fetchVisitorToken(inferenceUrl, app, visitor = keptVisitorId(), fetcher = fetch) {
52
+ const base = inferenceUrl.replace(/\/+$/, '');
53
+ const response = await fetcher(`${base}/api/ai-inference/v1/anonymous/token`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify(visitorTokenRequest(app, visitor)),
57
+ });
58
+ const body = (await response.json().catch(() => ({})));
59
+ if (!response.ok || !body.token) {
60
+ throw new Error(body.detail ||
61
+ `A visitor's token was refused by ${base} (HTTP ${response.status}).`);
62
+ }
63
+ if (isVisitorId(body.visitor)) {
64
+ keepVisitorId(body.visitor);
65
+ }
66
+ const lifetimeMs = Math.max(60_000, (body.expires_in ?? 300) * 1000);
67
+ return {
68
+ token: body.token,
69
+ visitor: body.visitor ?? visitor,
70
+ renewAt: Date.now() + lifetimeMs - MARGIN_MS,
71
+ };
72
+ }
73
+ /**
74
+ * The visitor's token for an application while `visitors` is set: minted at
75
+ * once and again before it runs out. `error` says why none could be had.
76
+ */
77
+ export function useVisitorToken(visitors) {
78
+ const [state, setState] = useState({});
79
+ const app = visitors?.app;
80
+ const inferenceUrl = visitors?.inferenceUrl;
81
+ useEffect(() => {
82
+ if (!app || !inferenceUrl) {
83
+ setState({});
84
+ return;
85
+ }
86
+ let cancelled = false;
87
+ let timer;
88
+ const mint = () => {
89
+ fetchVisitorToken(inferenceUrl, app)
90
+ .then(minted => {
91
+ if (cancelled) {
92
+ return;
93
+ }
94
+ setState({ token: minted.token });
95
+ timer = setTimeout(mint, Math.max(5_000, minted.renewAt - Date.now()));
96
+ })
97
+ .catch((error) => {
98
+ if (!cancelled) {
99
+ setState({
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ });
104
+ };
105
+ mint();
106
+ return () => {
107
+ cancelled = true;
108
+ if (timer) {
109
+ clearTimeout(timer);
110
+ }
111
+ };
112
+ }, [app, inferenceUrl]);
113
+ return state;
114
+ }
@@ -6,6 +6,7 @@ import type { FrontendToolDefinition } from '../../types/tools';
6
6
  import type { ContextSnapshotData } from '../../types/context';
7
7
  import type { AssistantCharacter } from '../../chat/assistant/characters';
8
8
  import type { AssistantCharacterData } from '../../chat/assistant/formats/types';
9
+ import type { PresenceState } from '../../chat/presence/presenceStatus';
9
10
  /** Lifecycle of the sandbox a workspace is attached to. */
10
11
  export type SandboxState = 'idle' | 'starting' | 'running' | 'stopping' | 'error';
11
12
  /** What the workspace knows about the sandbox behind it. */
@@ -983,5 +984,12 @@ export type ChatTurnContribution = {
983
984
  * reply as it streams included; empty again when the chat starts over.
984
985
  */
985
986
  conversation: ReadonlySignal<ConversationEntry[]>;
987
+ /**
988
+ * What the application is doing, as its chat says it beside its face
989
+ * (LOOP T-08) — waiting for the person on an approval, paused when its
990
+ * deployment is — for whoever acts it out away from the chat: the
991
+ * floating assistant (T-22).
992
+ */
993
+ presence: ReadonlySignal<PresenceState>;
986
994
  };
987
995
  export declare const LoopChatTurn: import("@datalayer/reactor").ContributionPoint<ChatTurnContribution>;
@@ -72,9 +72,11 @@ export function LoopEmbed({ agentId = '', showHeader = false, chatHeaderActions,
72
72
  preset.presence?.name,
73
73
  preset.presence?.face,
74
74
  preset.presence?.welcome,
75
+ preset.presence?.paused,
75
76
  preset.showTokenUsage,
76
77
  // By its content: an application's spec written inline is a new object each render.
77
78
  JSON.stringify(preset.datalayerCreatePayload ?? null),
79
+ JSON.stringify(preset.datalayerVisitors ?? null),
78
80
  preset.defaultEditor,
79
81
  preset.showViewSelector,
80
82
  preset.hideChatHeader,
@@ -22,6 +22,9 @@ export function DatalayerAgentBridge() {
22
22
  const agentsConfig = reactor.getConfig(AGENTS_PLUGIN_NAME);
23
23
  const agentSpecId = agentsConfig?.datalayerAgentSpecId ?? DEFAULT_DATALAYER_AGENTSPEC;
24
24
  const createPayload = agentsConfig?.datalayerCreatePayload;
25
+ // A conversation without an account (LOOP R-30): the agent is already on
26
+ // the visitors' runtime, kept warm there; nothing is allocated or made.
27
+ const visitors = agentsConfig?.datalayerVisitors;
25
28
  const service = useOptionalSandboxService();
26
29
  const target = useSignalValue(service?.target ?? IDLE_SANDBOX_TARGET_SIGNAL);
27
30
  const onDatalayer = target === 'datalayer';
@@ -65,13 +68,22 @@ export function DatalayerAgentBridge() {
65
68
  agentSpecId,
66
69
  agentConfig,
67
70
  variant: 'cloud-pydanticai',
68
- autoStart: onDatalayer,
69
- autoCreateAgent: onDatalayer,
71
+ autoStart: onDatalayer && !visitors,
72
+ autoCreateAgent: onDatalayer && !visitors,
70
73
  });
71
74
  useEffect(() => {
72
75
  if (!service || !onDatalayer) {
73
76
  return;
74
77
  }
78
+ if (visitors) {
79
+ service.report({
80
+ variant: 'datalayer',
81
+ // No sandbox: a visitor's application runs no code.
82
+ sandbox_running: false,
83
+ agent_base_url: visitors.url,
84
+ });
85
+ return;
86
+ }
75
87
  if (error) {
76
88
  // The reason travels with the state. "connected-dead" and a column of
77
89
  // "unknown" is what a reader saw before, which names the symptom and
@@ -106,7 +118,7 @@ export function DatalayerAgentBridge() {
106
118
  // host's server for an agent that is not on it.
107
119
  agent_base_url: runtime.agentBaseUrl,
108
120
  });
109
- }, [service, onDatalayer, runtime, status, error]);
121
+ }, [service, onDatalayer, runtime, status, error, visitors]);
110
122
  return null;
111
123
  }
112
124
  export default DatalayerAgentBridge;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * The Agents plugin: where the agent runs, and the sandbox that comes with it.
3
+ *
4
+ * It was the "code sandbox" plugin, and the rename follows what the choice
5
+ * came to mean. Picking a target used to say only where *code* ran; now it
6
+ * decides where the *agent* runs too — in this page with the Vercel AI SDK, or
7
+ * on a server with pydantic-ai — because the location is what a harness can
8
+ * be, whatever a spec asks for. One control, one answer.
9
+ *
10
+ * It owns the sandbox and publishes it as a service; the notebook and document
11
+ * plugins depend on this plugin rather than on a connection of their own.
12
+ *
13
+ * Which agentspec the session runs is a different question, and belongs to
14
+ * `@datalayer/loop-plugin-agentspecs`.
15
+ *
16
+ * @module loop/plugins/agents/plugin
17
+ */
18
+ import type { DatalayerVisitors } from '../../apps/visitorToken';
1
19
  import type { ReactorSlotComponent } from '@datalayer/reactor/react';
2
20
  import type { ServiceManagerSource } from './browserService';
3
21
  import { type TeamSelection } from './team';
@@ -79,6 +97,12 @@ export type AgentsConfig = {
79
97
  * decides its tool calls by its rules (LOOP R-05, R-03).
80
98
  */
81
99
  datalayerCreatePayload?: Record<string, unknown>;
100
+ /**
101
+ * A conversation without an account (LOOP R-30): the application's agent
102
+ * is already on the visitors' runtime, so nothing is allocated or created,
103
+ * and the chat speaks to it with a visitor's token for the application.
104
+ */
105
+ datalayerVisitors?: DatalayerVisitors;
82
106
  /**
83
107
  * The target is the host's decision, not the reader's: started there even
84
108
  * with the control hidden. For an application that runs where it is
@@ -2,23 +2,6 @@
2
2
  * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
- /**
6
- * The Agents plugin: where the agent runs, and the sandbox that comes with it.
7
- *
8
- * It was the "code sandbox" plugin, and the rename follows what the choice
9
- * came to mean. Picking a target used to say only where *code* ran; now it
10
- * decides where the *agent* runs too — in this page with the Vercel AI SDK, or
11
- * on a server with pydantic-ai — because the location is what a harness can
12
- * be, whatever a spec asks for. One control, one answer.
13
- *
14
- * It owns the sandbox and publishes it as a service; the notebook and document
15
- * plugins depend on this plugin rather than on a connection of their own.
16
- *
17
- * Which agentspec the session runs is a different question, and belongs to
18
- * `@datalayer/loop-plugin-agentspecs`.
19
- *
20
- * @module loop/plugins/agents/plugin
21
- */
22
5
  import { ServerIcon } from '@primer/octicons-react';
23
6
  import { contribution, definePlugin } from '@datalayer/reactor';
24
7
  import { LoopAgentBlueprint, LoopAgentGate, LoopCommand, LoopSlots, LoopViewType, } from '../../core';
@@ -3,10 +3,13 @@
3
3
  * page draws over its workspace, chosen from what the enabled plugins
4
4
  * contribute to `loop.assistant.character` — the application's own
5
5
  * (`interface.assistant`) when its Appspec names one, else the person's
6
- * choice from their settings, else the paper clip. It acts out the chat's
7
- * turn (T-22), says the agent's newest words in its balloon while the
8
- * conversation is out of sight (T-23), opens the conversation when clicked,
9
- * is dragged about and sent away as the chat's own assistant is (T-27).
6
+ * choice from their settings, else the paper clip. It acts out what the
7
+ * application is doing as its chat says it — paused too, when its deployment
8
+ * is — and the turn (T-22), says the agent's newest words in its balloon
9
+ * while the conversation is out of sight, holds there the approval it waits
10
+ * on, answered with *Approve* or *Deny* on the path every approval of it is
11
+ * answered on (T-23, U-19), opens the conversation when clicked, and is
12
+ * dragged about and sent away as the chat's own assistant is (T-27).
10
13
  *
11
14
  * The workspace's chat is the conversation: on a page layout, its side panel
12
15
  * is opened and closed; where the conversation is always on screen, a click
@@ -16,6 +19,7 @@
16
19
  */
17
20
  import type { JSX } from 'react';
18
21
  import { type AssistantState } from '../../../chat/assistant/state';
22
+ import type { PresenceState } from '../../../chat/presence/presenceStatus';
19
23
  import { type ChatTurnSnapshot } from '../../core';
20
24
  export declare const LOOP_ASSISTANT_PLUGIN_NAME = "@datalayer/loop-plugin-assistant";
21
25
  export type LoopAssistantConfig = {
@@ -23,16 +27,22 @@ export type LoopAssistantConfig = {
23
27
  app?: string;
24
28
  /** The character the person chose in their settings. */
25
29
  person?: string;
30
+ /**
31
+ * The application's id: the approvals it waits on are read and answered
32
+ * in the balloon (T-23), as its rules card reads them (U-19). Without it,
33
+ * the balloon says an approval waits and the conversation answers it.
34
+ */
35
+ appId?: string;
26
36
  };
27
37
  /**
28
- * What the character acts out, from the workspace's chat turn (T-22): it
29
- * thinks while the agent thinks, works while a tool runs (the turn's
30
- * `activity`), speaks while the answer is written, and idles otherwise.
38
+ * What the character acts out (T-22), from what the application is doing as
39
+ * its chat says it — idle, thinking, working, waiting for the person, paused
40
+ * — and the turn: it speaks while the answer is written.
31
41
  */
32
- export declare function assistantStateOfTurn(turn: ChatTurnSnapshot, moment?: {
42
+ export declare function assistantStateOfTurn(turn: ChatTurnSnapshot, presence: PresenceState, moment?: {
33
43
  arriving?: boolean;
34
44
  leaving?: boolean;
35
45
  }): AssistantState;
36
- export declare function LoopAssistant({ app, person, }: LoopAssistantConfig): JSX.Element | null;
46
+ export declare function LoopAssistant({ app, person, appId, }: LoopAssistantConfig): JSX.Element | null;
37
47
  export declare const LoopAssistantPlugin: import("@datalayer/reactor").ReactorPlugin<LoopAssistantConfig, unknown, unknown>;
38
48
  export default LoopAssistantPlugin;
@@ -1,47 +1,80 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { definePlugin, signal } from '@datalayer/reactor';
4
4
  import { useContributions, useSignalValue } from '@datalayer/reactor/react';
5
5
  import { Text } from '@primer/react';
6
6
  import { Box } from '@datalayer/primer-addons';
7
+ import { useIAMStore } from '@datalayer/core/lib/state/substates/IAMState';
7
8
  import { pageLayoutPanelOpen, togglePagePanel, } from '@datalayer/primer-addons/lib/reactor';
8
9
  import { AssistantStage } from '../../../chat/assistant/AssistantStage';
9
- import { assistantStateOf, keepAway, keptAway, latestSaying, } from '../../../chat/assistant/state';
10
+ import { ASSISTANT_WORDS, assistantStateOf, keepAway, keptAway, latestSaying, } from '../../../chat/assistant/state';
11
+ import { useApproveToolRequest, useRejectToolRequest, useToolApprovalsQuery, } from '../../../hooks/useToolApprovals';
12
+ import { approvalsOfApp, ruleOfApproval } from '../app-rules/AppRulesCard';
10
13
  import { useViewportDrag } from '../../../chat/useViewportDrag';
11
14
  import { LoopAssistantCharacter, LoopChatLayout, LoopChatTurn, LoopSlots, } from '../../core';
12
15
  import { assistantCharacterFor } from '../assistant-characters';
13
16
  export const LOOP_ASSISTANT_PLUGIN_NAME = '@datalayer/loop-plugin-assistant';
14
17
  /** The character's size, in pixels, as the chat's own assistant. */
15
18
  const SIZE = 88;
16
- /* A signal to read when no chat contributed a turn: the hook needs one. */
19
+ /* Signals to read when no chat contributed a turn: the hooks need one. */
17
20
  const NO_TURN = signal({ id: 0, status: 'idle' });
21
+ const NO_PRESENCE = signal('idle');
18
22
  /**
19
- * What the character acts out, from the workspace's chat turn (T-22): it
20
- * thinks while the agent thinks, works while a tool runs (the turn's
21
- * `activity`), speaks while the answer is written, and idles otherwise.
23
+ * What the character acts out (T-22), from what the application is doing as
24
+ * its chat says it — idle, thinking, working, waiting for the person, paused
25
+ * — and the turn: it speaks while the answer is written.
22
26
  */
23
- export function assistantStateOfTurn(turn, moment = {}) {
24
- const busy = turn.status === 'thinking' || turn.status === 'streaming';
25
- const presence = !busy
26
- ? 'idle'
27
- : turn.activity
28
- ? 'working'
29
- : 'thinking';
27
+ export function assistantStateOfTurn(turn, presence, moment = {}) {
30
28
  return assistantStateOf(presence, {
31
29
  ...moment,
32
30
  speaking: turn.status === 'streaming' && !turn.activity,
33
31
  });
34
32
  }
33
+ const PENDING = { status: 'pending' };
34
+ /**
35
+ * The approvals an application waits on, read and answered as its rules
36
+ * card does (U-19): over the ai-agents `/ws`, where every decision is
37
+ * broadcast, so one answered here leaves every list. The first is offered
38
+ * to the balloon; none, once all are answered.
39
+ */
40
+ function WaitingApprovals({ appId, onWaiting, }) {
41
+ const query = useToolApprovalsQuery(PENDING);
42
+ const approve = useApproveToolRequest();
43
+ const reject = useRejectToolRequest();
44
+ const waiting = useMemo(() => approvalsOfApp(query.data?.approvals ?? [], appId), [query.data, appId]);
45
+ const first = waiting[0];
46
+ const deciding = approve.isPending || reject.isPending;
47
+ useEffect(() => {
48
+ onWaiting(first
49
+ ? {
50
+ id: first.id,
51
+ asks: first.tool_name,
52
+ why: ruleOfApproval(first) || undefined,
53
+ others: waiting.length - 1,
54
+ deciding,
55
+ onApprove: () => approve.mutate({ id: first.id }),
56
+ onDeny: () => reject.mutate({ id: first.id }),
57
+ }
58
+ : undefined);
59
+ // The mutations are new objects each render; the decision they send is not.
60
+ // eslint-disable-next-line react-hooks/exhaustive-deps
61
+ }, [first?.id, waiting.length, deciding, onWaiting]);
62
+ useEffect(() => () => onWaiting(undefined), [onWaiting]);
63
+ return null;
64
+ }
35
65
  /** Take the person to the workspace's composer. */
36
66
  function focusComposer() {
37
67
  const composer = document.querySelector('[data-chat-composer] textarea, [data-chat-composer] [contenteditable="true"]');
38
68
  composer?.focus();
39
69
  }
40
- export function LoopAssistant({ app, person, }) {
70
+ export function LoopAssistant({ app, person, appId, }) {
41
71
  const contributed = useContributions(LoopAssistantCharacter).map(entry => entry.value);
42
72
  const chosen = assistantCharacterFor(contributed, { app, person });
43
73
  const turnEntries = useContributions(LoopChatTurn);
44
74
  const turn = useSignalValue(turnEntries[0]?.value.turn ?? NO_TURN);
75
+ const presence = useSignalValue(turnEntries[0]?.value.presence ?? NO_PRESENCE);
76
+ const signedIn = Boolean(useIAMStore(state => state.token));
77
+ const [approval, setApproval] = useState();
45
78
  // On a page layout the conversation is its side panel; elsewhere it is
46
79
  // always on screen.
47
80
  const onPageLayout = useContributions(LoopChatLayout).some(entry => entry.value.id === 'page-layout');
@@ -96,12 +129,21 @@ export function LoopAssistant({ app, person, }) {
96
129
  boxShadow: 'shadow.medium',
97
130
  }, children: _jsx(Text, { sx: { fontSize: 1 }, children: chosen.problem }) }));
98
131
  }
99
- const state = assistantStateOfTurn(turn, { arriving, leaving });
132
+ const state = assistantStateOfTurn(turn, presence, { arriving, leaving });
100
133
  const unheard = !!saying && saying.id !== heardId;
101
- const balloon = unheard && saying
102
- ? { text: saying.text, more: saying.more }
103
- : { text: 'Click me to open the conversation.' };
104
- const insist = state === 'greeting' || (unheard && (state === 'speaking' || fresh));
134
+ const balloon = state === 'paused'
135
+ ? { text: ASSISTANT_WORDS.paused }
136
+ : approval
137
+ ? { text: ASSISTANT_WORDS.approval, approval }
138
+ : presence === 'waiting'
139
+ ? { text: ASSISTANT_WORDS.waiting }
140
+ : unheard && saying
141
+ ? { text: saying.text, more: saying.more }
142
+ : { text: 'Click me to open the conversation.' };
143
+ const insist = state === 'greeting' ||
144
+ presence === 'waiting' ||
145
+ (!!approval && state !== 'paused') ||
146
+ (unheard && (state === 'speaking' || fresh));
105
147
  const onToggle = () => {
106
148
  if (onPageLayout) {
107
149
  togglePagePanel();
@@ -118,18 +160,18 @@ export function LoopAssistant({ app, person, }) {
118
160
  setAway(next);
119
161
  }, 600);
120
162
  };
121
- return (_jsx(AssistantStage, { character: chosen.character, state: state, size: SIZE, place: drag.position
122
- ? { left: `${drag.position.left}px`, top: `${drag.position.top}px` }
123
- : { right: '24px', bottom: '24px' }, stageRef: stageRef, onDragStart: drag.onHandlePointerDown, open: open, onToggle: onToggle, balloon: balloon, insist: insist, onDismiss: onDismiss }));
163
+ return (_jsxs(_Fragment, { children: [appId && signedIn ? (_jsx(WaitingApprovals, { appId: appId, onWaiting: setApproval })) : null, _jsx(AssistantStage, { character: chosen.character, state: state, size: SIZE, place: drag.position
164
+ ? { left: `${drag.position.left}px`, top: `${drag.position.top}px` }
165
+ : { right: '24px', bottom: '24px' }, stageRef: stageRef, onDragStart: drag.onHandlePointerDown, open: open, onToggle: onToggle, balloon: balloon, insist: insist, onDismiss: onDismiss })] }));
124
166
  }
125
167
  export const LoopAssistantPlugin = definePlugin({
126
168
  name: LOOP_ASSISTANT_PLUGIN_NAME,
127
- config: { app: undefined, person: undefined },
169
+ config: { app: undefined, person: undefined, appId: undefined },
128
170
  displayName: 'Floating assistant',
129
171
  description: 'The chat as a character on the page: the application’s own, or the one the person chose, from the characters the enabled plugins contribute.',
130
172
  octicon: 'paperclip',
131
173
  build: ({ config }) => {
132
- const Configured = () => (_jsx(LoopAssistant, { app: config.app, person: config.person }));
174
+ const Configured = () => (_jsx(LoopAssistant, { app: config.app, person: config.person, appId: config.appId }));
133
175
  return {
134
176
  components: [
135
177
  { id: 'loop-assistant', slot: LoopSlots.root, Component: Configured },
@@ -36,6 +36,8 @@ import { CHAT_PLUGIN_NAME } from './index';
36
36
  import { INPUT_PROMPT_PLUGIN_NAME, } from '../input-prompt';
37
37
  import { agentRuntimeStore, useAgentRuntimeContextSnapshot, } from '../../../stores';
38
38
  import { useIAMStore } from '../../../state';
39
+ import { useVisitorToken } from '../../apps/visitorToken';
40
+ import { AGENTS_PLUGIN_NAME } from '../agents/plugin';
39
41
  import { useConfig } from '../../../hooks/useConfig';
40
42
  import { useSkills, useSkillActions } from '../../../hooks/useSkills';
41
43
  import { AI_MODEL_CATALOGUE, isChatModel } from '../../../specs/models';
@@ -199,11 +201,13 @@ export default function ChatView({ workspace }) {
199
201
  open: false,
200
202
  pendingApproval: false,
201
203
  });
202
- const presenceNow = presenceState(busy, presenceTool);
204
+ const presenceNow = presenceState(busy, presenceTool, config?.presence?.paused === true);
203
205
  const onPresence = config?.presence?.onPresence;
204
- // A host drawing the face in a frame of its own is told as it changes.
206
+ // A host drawing the face in a frame of its own is told as it changes, and
207
+ // so is whoever reads the turn: the floating assistant acts it out (T-22).
205
208
  useEffect(() => {
206
209
  onPresence?.(presenceNow);
210
+ turnFeedRef.current?.setPresence(presenceNow);
207
211
  }, [onPresence, presenceNow]);
208
212
  // What it last said, for a host that says it outside the conversation —
209
213
  // the embed's floating chrome (LOOP R-01): its balloon and its blink.
@@ -1135,7 +1139,14 @@ export default function ChatView({ workspace }) {
1135
1139
  * chat says so with the person's token. The embed hands its own.
1136
1140
  */
1137
1141
  const runsApp = Boolean(blueprintTurn?.createPayload?.app_spec);
1138
- const iamToken = useIAMStore(state => state.token);
1142
+ const memberToken = useIAMStore(state => state.token);
1143
+ /*
1144
+ * Without an account (LOOP R-30) the application runs on the visitors'
1145
+ * runtime, which answers a visitor's token for it and nobody signed in.
1146
+ */
1147
+ const visitors = reactor.getConfig(AGENTS_PLUGIN_NAME)?.datalayerVisitors;
1148
+ const visitor = useVisitorToken(visitors);
1149
+ const iamToken = visitors ? visitor.token : memberToken;
1139
1150
  const protocol = useMemo(() => inPage
1140
1151
  ? browserProtocolConfig({
1141
1152
  agentId: member?.specId ?? agentId,
@@ -9,9 +9,14 @@ export interface ChatPresence {
9
9
  face?: string;
10
10
  /** What it says before anyone writes. */
11
11
  welcome?: string;
12
+ /**
13
+ * Its deployment is paused (LOOP R-17): it says so beside its face, and
14
+ * its assistant dozes (T-08, T-22), whatever its last turn left behind.
15
+ */
16
+ paused?: boolean;
12
17
  /**
13
18
  * Told what it is doing as it changes — idle, thinking, working, waiting
14
- * for you — for a host that draws the face itself, in a frame of its own.
19
+ * for you, paused — for a host that draws the face itself, in a frame of its own.
15
20
  * Read once, when the workspace is built: pass a stable function, such as
16
21
  * a state setter.
17
22
  */
@@ -86,6 +86,7 @@ export const ChatPlugin = definePlugin({
86
86
  id: 'chat-turn',
87
87
  turn: feed.turn,
88
88
  conversation: feed.conversation,
89
+ presence: feed.presence,
89
90
  ...feedWriters(feed),
90
91
  }, { id: 'chat-turn' });
91
92
  return {};
@@ -17,6 +17,7 @@ import { type ReadonlySignal } from '@datalayer/reactor';
17
17
  import type { ContextSnapshotData } from '../../../types';
18
18
  import type { DisplayItem } from '../../../types/chat';
19
19
  import type { ChatMessage } from '../../../types/messages';
20
+ import type { PresenceState } from '../../../chat/presence/presenceStatus';
20
21
  import type { ChatTurnSnapshot, ChatTurnStatus, ConversationEntry } from '../../core';
21
22
  export type TurnFeed = {
22
23
  /** The turn, for readers. */
@@ -38,6 +39,10 @@ export type TurnFeed = {
38
39
  conversation: ReadonlySignal<ConversationEntry[]>;
39
40
  /** The chat's items as they now stand, streaming included. */
40
41
  items: (items: DisplayItem[]) => void;
42
+ /** What the application is doing, as its chat says it (T-08), for readers. */
43
+ presence: ReadonlySignal<PresenceState>;
44
+ /** What the application is doing now. */
45
+ setPresence: (state: PresenceState) => void;
41
46
  };
42
47
  /** A message's words, whether its content is a string or parts. */
43
48
  export declare function messageText(message: ChatMessage): string;
@@ -65,9 +65,16 @@ const IDLE = { id: 0, status: 'idle' };
65
65
  export function createTurnFeed() {
66
66
  const turn = signal(IDLE);
67
67
  const conversation = signal([]);
68
+ const presence = signal('idle');
68
69
  return {
69
70
  turn,
70
71
  conversation,
72
+ presence,
73
+ setPresence: state => {
74
+ if (presence.value !== state) {
75
+ presence.value = state;
76
+ }
77
+ },
71
78
  items: items => {
72
79
  const next = conversationOf(items);
73
80
  // Nothing new: readers do not re-render for an identical conversation.
@@ -137,6 +137,8 @@ export type LoopPresetOptions = {
137
137
  datalayerAgentSpecId?: string;
138
138
  /** What the agent on that runtime is created with besides its spec — an `app_spec`. */
139
139
  datalayerCreatePayload?: Record<string, unknown>;
140
+ /** A conversation without an account, on the visitors' runtime (LOOP R-30). */
141
+ datalayerVisitors?: AgentsConfig['datalayerVisitors'];
140
142
  /** The target is the host's to fix, even with the agent control hidden. */
141
143
  targetFixed?: boolean;
142
144
  /** The theme the conversation wears, by name; the person's unless said. */
@@ -52,7 +52,7 @@ 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, colorMode, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, conversation = 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, datalayerVisitors, targetFixed, themeVariant, themeOverrides, colorMode, 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
57
  // A workspace without a conversation: the theme, the shell's points, and
58
58
  // the host's chrome; its view is a host's plugin's.
@@ -111,6 +111,7 @@ export function loopPlugins(options = {}) {
111
111
  target,
112
112
  datalayerAgentSpecId,
113
113
  datalayerCreatePayload,
114
+ datalayerVisitors,
114
115
  targetFixed,
115
116
  showAgentVariants,
116
117
  showAgentSummary: agentSummary,
@@ -4,7 +4,7 @@ import { type ThemeVariant } from '@datalayer/primer-addons';
4
4
  export declare const REFERENCE_SCREENS: readonly ["conversation", "beside-work", "worker-activity", "approval"];
5
5
  export type ReferenceScreen = (typeof REFERENCE_SCREENS)[number];
6
6
  /** The floating assistant's pictures (T-27): its states, and stepped aside. */
7
- export declare const ASSISTANT_PICTURES: readonly ["idle", "thinking", "working", "waiting", "speaking", "aside"];
7
+ export declare const ASSISTANT_PICTURES: readonly ["idle", "thinking", "working", "waiting", "paused", "speaking", "aside"];
8
8
  export type AssistantPicture = (typeof ASSISTANT_PICTURES)[number];
9
9
  /**
10
10
  * The characters pictured idle, each in light and dark (T-25, T-24):
@@ -26,7 +26,8 @@ import { ChatBaseHeader } from '../../chat/header/ChatHeaderBase';
26
26
  import { ChatMessageList } from '../../chat/messages/ChatMessageList';
27
27
  import { InputPrompt } from '../../chat/prompt/InputPrompt';
28
28
  import { PresenceFace, PresenceLine } from '../../chat/presence/Presence';
29
- import { AssistantStage } from '../../chat/assistant/AssistantStage';
29
+ import { AssistantStage, } from '../../chat/assistant/AssistantStage';
30
+ import { ASSISTANT_WORDS, } from '../../chat/assistant/state';
30
31
  import { buildReactorFromPlugins } from '@datalayer/reactor';
31
32
  import { AssistantCharactersPlugin, assistantCharacterNamed, } from '../../loop/plugins/assistant-characters';
32
33
  import { OwlCharacterPlugin } from '../../examples/utils/owlCharacterPlugin';
@@ -43,6 +44,7 @@ export const ASSISTANT_PICTURES = [
43
44
  'thinking',
44
45
  'working',
45
46
  'waiting',
47
+ 'paused',
46
48
  'speaking',
47
49
  'aside',
48
50
  ];
@@ -210,7 +212,19 @@ const BALLOONS = {
210
212
  text: 'Three customers wrote about late deliveries this week: Ada, Grace and Alan.',
211
213
  more: true,
212
214
  },
213
- waiting: { text: 'Waiting for you: send the reply to Ada?' },
215
+ // An approval it waits on, answered in the balloon (T-23).
216
+ waiting: {
217
+ text: ASSISTANT_WORDS.approval,
218
+ approval: {
219
+ id: 'picture',
220
+ asks: 'send_reply',
221
+ why: 'Send a reply to a customer: ask me first',
222
+ others: 1,
223
+ onApprove: () => undefined,
224
+ onDeny: () => undefined,
225
+ },
226
+ },
227
+ paused: { text: ASSISTANT_WORDS.paused },
214
228
  };
215
229
  /**
216
230
  * The paper clip in one state, in the page's corner. `aside` puts a dialog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.52",
3
+ "version": "1.3.54",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -147,7 +147,7 @@
147
147
  "@datalayer/agent-teams": "^1.0.0",
148
148
  "@datalayer/core": "^1.2.14",
149
149
  "@datalayer/decks": "^1.0.4",
150
- "@datalayer/icons-react": "^1.0.14",
150
+ "@datalayer/icons-react": "^1.0.16",
151
151
  "@datalayer/jupyter-lexical": "^2.0.18",
152
152
  "@datalayer/jupyter-react": "^2.0.18",
153
153
  "@datalayer/jupyter-server-nbmodel": "^0.2.8",