@datalayer/agent-runtimes 1.3.27 → 1.3.29

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 (58) hide show
  1. package/lib/chat/ChatFloating.d.ts +12 -2
  2. package/lib/chat/ChatFloating.js +196 -25
  3. package/lib/chat/assistant/AssistantStage.d.ts +70 -0
  4. package/lib/chat/assistant/AssistantStage.js +192 -0
  5. package/lib/chat/assistant/SpeechBalloon.d.ts +26 -0
  6. package/lib/chat/assistant/SpeechBalloon.js +46 -0
  7. package/lib/chat/assistant/SpriteCharacter.d.ts +26 -0
  8. package/lib/chat/assistant/SpriteCharacter.js +73 -0
  9. package/lib/chat/assistant/characters.d.ts +30 -0
  10. package/lib/chat/assistant/characters.js +41 -0
  11. package/lib/chat/assistant/formats/acs.d.ts +88 -0
  12. package/lib/chat/assistant/formats/acs.js +493 -0
  13. package/lib/chat/assistant/formats/agentCompression.d.ts +5 -0
  14. package/lib/chat/assistant/formats/agentCompression.js +92 -0
  15. package/lib/chat/assistant/formats/blobs.d.ts +6 -0
  16. package/lib/chat/assistant/formats/blobs.js +52 -0
  17. package/lib/chat/assistant/formats/clippy.d.ts +21 -0
  18. package/lib/chat/assistant/formats/clippy.js +167 -0
  19. package/lib/chat/assistant/formats/index.d.ts +13 -0
  20. package/lib/chat/assistant/formats/index.js +15 -0
  21. package/lib/chat/assistant/formats/objectLiteral.d.ts +23 -0
  22. package/lib/chat/assistant/formats/objectLiteral.js +233 -0
  23. package/lib/chat/assistant/formats/stateAnimations.d.ts +16 -0
  24. package/lib/chat/assistant/formats/stateAnimations.js +115 -0
  25. package/lib/chat/assistant/formats/types.d.ts +56 -0
  26. package/lib/chat/assistant/formats/types.js +11 -0
  27. package/lib/chat/assistant/state.d.ts +62 -0
  28. package/lib/chat/assistant/state.js +116 -0
  29. package/lib/chat/header/ChatViewModeToggle.js +6 -3
  30. package/lib/chat/presence/Presence.d.ts +9 -0
  31. package/lib/chat/presence/Presence.js +57 -0
  32. package/lib/chat/presence/presenceStatus.d.ts +32 -0
  33. package/lib/chat/presence/presenceStatus.js +46 -0
  34. package/lib/chat/viewModes.d.ts +6 -0
  35. package/lib/chat/viewModes.js +10 -1
  36. package/lib/examples/ChatAssistantExample.d.ts +8 -0
  37. package/lib/examples/ChatAssistantExample.js +59 -0
  38. package/lib/examples/example-selector.js +1 -0
  39. package/lib/examples/main.js +3 -0
  40. package/lib/loop/apps/AppRenderer.d.ts +8 -1
  41. package/lib/loop/apps/AppRenderer.js +2 -1
  42. package/lib/loop/apps/appspec.d.ts +5 -1
  43. package/lib/loop/apps/appspec.js +21 -1
  44. package/lib/loop/apps/checks.js +3 -1
  45. package/lib/loop/core/index.d.ts +15 -0
  46. package/lib/loop/core/index.js +1 -0
  47. package/lib/loop/plugins/assistant-characters/index.d.ts +30 -0
  48. package/lib/loop/plugins/assistant-characters/index.js +41 -0
  49. package/lib/loop/plugins/chat/ChatView.js +21 -1
  50. package/lib/loop/plugins/chat/index.d.ts +8 -0
  51. package/lib/loop/plugins/page-layout/index.js +5 -1
  52. package/lib/specs/actions.js +6 -0
  53. package/lib/specs/apps.d.ts +3 -0
  54. package/lib/specs/apps.js +793 -65
  55. package/lib/specs/appspecSchema.js +20 -2
  56. package/lib/types/agentspecs.d.ts +10 -1
  57. package/lib/types/chat.d.ts +1 -1
  58. package/package.json +1 -1
@@ -12,6 +12,8 @@
12
12
  * @module chat/ChatFloating
13
13
  */
14
14
  import React from 'react';
15
+ import { type AssistantCharacter } from './assistant/characters';
16
+ import type { AssistantCharacterData } from './assistant/formats/types';
15
17
  import type { ChatCommonProps, ChatViewMode } from '../types';
16
18
  /**
17
19
  * ChatFloating props — extends ChatCommonProps with floating/popup-specific configuration.
@@ -67,10 +69,18 @@ export interface ChatFloatingProps extends ChatCommonProps {
67
69
  * - 'floating': Full-height floating panel (pinned to right edge with offset)
68
70
  * - 'floating-small': Standard floating popup
69
71
  * - 'floating-draggable': The popup with a handle, movable about the viewport
72
+ * - 'assistant': A character on the page that speaks in a balloon (LOOP T-21)
70
73
  * - 'panel': Full-height side panel (right edge, no floating offset)
71
74
  * @default 'floating'
72
75
  */
73
- defaultViewMode?: 'floating' | 'floating-small' | 'floating-draggable' | 'panel';
76
+ defaultViewMode?: 'floating' | 'floating-small' | 'floating-draggable' | 'assistant' | 'panel';
77
+ /**
78
+ * The character of the floating assistant: one Datalayer ships, by id
79
+ * (`ASSISTANT_CHARACTERS`, LOOP T-25), or one a person loaded from a file
80
+ * they hold the rights to (`readClippyCharacter`, `readAcsCharacter`, T-26).
81
+ * @default 'paperclip'
82
+ */
83
+ assistantCharacter?: string | AssistantCharacter | AssistantCharacterData;
74
84
  /**
75
85
  * Callback when the user switches view mode via the header toggle.
76
86
  * The parent component receives the new ChatViewMode value.
@@ -99,5 +109,5 @@ export interface ChatFloatingProps extends ChatCommonProps {
99
109
  * ChatFloating component
100
110
  * A floating chat window built on ChatBase
101
111
  */
102
- export declare function ChatFloating({ endpoint, protocol: protocolProp, useStore: useStoreMode, title, description, position, defaultOpen, width, height, showHeader, showButton, showNewChatButton, showClearButton, showSettingsButton, enableKeyboardShortcuts, toggleShortcut, showPoweredBy, promptVariant, mentionableAgents, poweredByProps, clickOutsideToClose, escapeToClose, className, onSettingsClick, onNewChat, onOpen, onClose, onStateUpdate, children, brandIcon, buttonIcon, buttonTooltip, brandColor, buttonGlow, offset, animationDuration, renderToolResult, frontendTools, initialState: _initialState, suggestions, submitOnSuggestionClick, hideMessagesAfterToolUI, defaultViewMode, onViewModeChange, sidebarMountPoint, showPanelBackdrop, availableModels, showModelSelector, showToolsMenu, showSkillsMenu, showTokenUsage, showContextRing, themeVariant, colorMode, runtimeId, historyEndpoint, authToken, historyAuthToken, pendingPrompt, showInformation, onInformationClick, onToolCallStart, onToolCallComplete, showToolApprovalBanner, pendingApprovals, onApproveApproval, onRejectApproval, panelProps, launching, launchingMessage, }: ChatFloatingProps): React.JSX.Element;
112
+ export declare function ChatFloating({ endpoint, protocol: protocolProp, useStore: useStoreMode, title, description, position, defaultOpen, width, height, showHeader, showButton, showNewChatButton, showClearButton, showSettingsButton, enableKeyboardShortcuts, toggleShortcut, showPoweredBy, promptVariant, mentionableAgents, poweredByProps, clickOutsideToClose, escapeToClose, className, onSettingsClick, onNewChat, onOpen, onClose, onStateUpdate, children, brandIcon, buttonIcon, buttonTooltip, brandColor, buttonGlow, offset, animationDuration, renderToolResult, frontendTools, initialState: _initialState, suggestions, submitOnSuggestionClick, hideMessagesAfterToolUI, defaultViewMode, onViewModeChange, sidebarMountPoint, showPanelBackdrop, availableModels, showModelSelector, showToolsMenu, showSkillsMenu, showTokenUsage, showContextRing, themeVariant, colorMode, runtimeId, historyEndpoint, authToken, historyAuthToken, pendingPrompt, showInformation, onInformationClick, onToolCallStart, onToolCallComplete, showToolApprovalBanner, pendingApprovals, onApproveApproval, onRejectApproval, panelProps, launching, launchingMessage, assistantCharacter, }: ChatFloatingProps): React.JSX.Element;
103
113
  export default ChatFloating;
@@ -26,6 +26,11 @@ import { ChatBase } from './base/ChatBase';
26
26
  import { ButtonGlow } from './display/ButtonGlow';
27
27
  import { useViewportDrag } from './useViewportDrag';
28
28
  import { disabledChatViewModes, resolveMountPoint } from './viewModes';
29
+ import { AssistantStage } from './assistant/AssistantStage';
30
+ import { SpeechBalloon } from './assistant/SpeechBalloon';
31
+ import { DEFAULT_ASSISTANT_CHARACTER, } from './assistant/characters';
32
+ import { assistantStateOf, latestSaying, newestIsAnswer, keepAway, keptAway, } from './assistant/state';
33
+ import { presenceState, presenceToolOf, } from './presence/presenceStatus';
29
34
  import { useChatOpen, useChatMessages, useChatStore, } from '../stores/chatStore';
30
35
  import { useChatKeyboardShortcuts, getShortcutDisplay, } from '@datalayer/core/lib/hooks';
31
36
  /**
@@ -47,7 +52,7 @@ function useIsMobile(breakpoint = 640) {
47
52
  * ChatFloating component
48
53
  * A floating chat window built on ChatBase
49
54
  */
50
- export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useStoreMode = true, title = 'Chat', description = 'Start a conversation with the AI agent.', position = 'bottom-right', defaultOpen = false, width = 400, height = 550, showHeader = true, showButton = true, showNewChatButton = true, showClearButton = true, showSettingsButton = false, enableKeyboardShortcuts = true, toggleShortcut = '/', showPoweredBy = true, promptVariant, mentionableAgents, poweredByProps, clickOutsideToClose = false, escapeToClose = true, className, onSettingsClick, onNewChat, onOpen, onClose, onStateUpdate, children, brandIcon, buttonIcon, buttonTooltip = 'Chat with AI', brandColor, buttonGlow = true, offset = 20, animationDuration = 200, renderToolResult, frontendTools, initialState: _initialState, suggestions, submitOnSuggestionClick = true, hideMessagesAfterToolUI = false, defaultViewMode = 'floating', onViewModeChange, sidebarMountPoint = null, showPanelBackdrop = false, availableModels, showModelSelector = false, showToolsMenu = false, showSkillsMenu = false, showTokenUsage = true, showContextRing = false, themeVariant, colorMode, runtimeId, historyEndpoint, authToken, historyAuthToken, pendingPrompt, showInformation = false, onInformationClick, onToolCallStart, onToolCallComplete, showToolApprovalBanner, pendingApprovals, onApproveApproval, onRejectApproval, panelProps, launching = false, launchingMessage, }) {
55
+ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useStoreMode = true, title = 'Chat', description = 'Start a conversation with the AI agent.', position = 'bottom-right', defaultOpen = false, width = 400, height = 550, showHeader = true, showButton = true, showNewChatButton = true, showClearButton = true, showSettingsButton = false, enableKeyboardShortcuts = true, toggleShortcut = '/', showPoweredBy = true, promptVariant, mentionableAgents, poweredByProps, clickOutsideToClose = false, escapeToClose = true, className, onSettingsClick, onNewChat, onOpen, onClose, onStateUpdate, children, brandIcon, buttonIcon, buttonTooltip = 'Chat with AI', brandColor, buttonGlow = true, offset = 20, animationDuration = 200, renderToolResult, frontendTools, initialState: _initialState, suggestions, submitOnSuggestionClick = true, hideMessagesAfterToolUI = false, defaultViewMode = 'floating', onViewModeChange, sidebarMountPoint = null, showPanelBackdrop = false, availableModels, showModelSelector = false, showToolsMenu = false, showSkillsMenu = false, showTokenUsage = true, showContextRing = false, themeVariant, colorMode, runtimeId, historyEndpoint, authToken, historyAuthToken, pendingPrompt, showInformation = false, onInformationClick, onToolCallStart, onToolCallComplete, showToolApprovalBanner, pendingApprovals, onApproveApproval, onRejectApproval, panelProps, launching = false, launchingMessage, assistantCharacter = DEFAULT_ASSISTANT_CHARACTER, }) {
51
56
  // Store-based state
52
57
  const storeIsOpen = useChatOpen();
53
58
  const storeMessages = useChatMessages();
@@ -96,6 +101,88 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
96
101
  resetDrag();
97
102
  }
98
103
  }, [viewMode, resetDrag]);
104
+ /*
105
+ * The floating assistant (LOOP T-21, T-22, T-27): a character that acts out
106
+ * the presence of what answers — read here from the chat's own loading and
107
+ * items — greets when it arrives, says goodbye when sent away, and is
108
+ * dragged about by itself, the conversation following it.
109
+ */
110
+ const ASSISTANT_SIZE = 88;
111
+ const assistantMode = viewMode === 'assistant' && !isMobile;
112
+ const [chatBusy, setChatBusy] = useState(false);
113
+ const [chatTool, setChatTool] = useState({
114
+ open: false,
115
+ pendingApproval: false,
116
+ });
117
+ const [answering, setAnswering] = useState(false);
118
+ const [arriving, setArriving] = useState(false);
119
+ const [leaving, setLeaving] = useState(false);
120
+ const [assistantAway, setAssistantAway] = useState(keptAway);
121
+ const assistantShown = assistantMode && assistantAway === 'none';
122
+ const callAssistantBack = useCallback(() => {
123
+ keepAway('none');
124
+ setAssistantAway('none');
125
+ }, []);
126
+ /* The agent's newest words, said in the balloon until heard (T-23). */
127
+ const [saying, setSaying] = useState();
128
+ const [heardId, setHeardId] = useState();
129
+ const [freshSaying, setFreshSaying] = useState(false);
130
+ useEffect(() => {
131
+ if (!saying || isOpen) {
132
+ return;
133
+ }
134
+ setFreshSaying(true);
135
+ const timer = setTimeout(() => setFreshSaying(false), 12000);
136
+ return () => clearTimeout(timer);
137
+ }, [saying?.id, saying?.text, isOpen]);
138
+ useEffect(() => {
139
+ if (isOpen && saying) {
140
+ setHeardId(saying.id);
141
+ }
142
+ }, [isOpen, saying]);
143
+ const stageRef = useRef(null);
144
+ const stageDrag = useViewportDrag(stageRef);
145
+ useEffect(() => {
146
+ if (!assistantShown) {
147
+ return;
148
+ }
149
+ setArriving(true);
150
+ const timer = setTimeout(() => setArriving(false), 1800);
151
+ return () => clearTimeout(timer);
152
+ }, [assistantShown]);
153
+ const assistantState = assistantStateOf(presenceState(chatBusy, chatTool), {
154
+ arriving,
155
+ leaving,
156
+ speaking: answering,
157
+ });
158
+ const unheard = !!saying && saying.id !== heardId;
159
+ const assistantBalloon = assistantState === 'waiting'
160
+ ? { text: 'Waiting for you — open the conversation to answer.' }
161
+ : unheard && saying
162
+ ? { text: saying.text, more: saying.more }
163
+ : { text: description };
164
+ const balloonInsists = assistantState === 'greeting' ||
165
+ assistantState === 'waiting' ||
166
+ (unheard && (assistantState === 'speaking' || freshSaying));
167
+ const panelOnLoadingChange = panelProps?.onLoadingChange;
168
+ const panelOnDisplayItemsChange = panelProps?.onDisplayItemsChange;
169
+ const handleLoadingChange = useCallback((loading) => {
170
+ setChatBusy(loading);
171
+ panelOnLoadingChange?.(loading);
172
+ }, [panelOnLoadingChange]);
173
+ const handleDisplayItemsChange = useCallback((items) => {
174
+ const tool = presenceToolOf(items);
175
+ setChatTool(previous => previous.open === tool.open &&
176
+ previous.pendingApproval === tool.pendingApproval
177
+ ? previous
178
+ : tool);
179
+ setAnswering(newestIsAnswer(items));
180
+ const said = latestSaying(items);
181
+ setSaying(previous => previous?.id === said?.id && previous?.text === said?.text
182
+ ? previous
183
+ : said);
184
+ panelOnDisplayItemsChange?.(items);
185
+ }, [panelOnDisplayItemsChange]);
99
186
  // Handle view mode changes from the header segmented toggle
100
187
  const handleChatViewModeChange = useCallback((mode) => {
101
188
  if (mode === 'sidebar' && sidebarDisabled) {
@@ -115,12 +202,16 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
115
202
  setFocusTrigger(prev => prev + 1);
116
203
  }
117
204
  else {
118
- // The floating modes stay within ChatFloating
205
+ // The floating modes stay within ChatFloating; picking the assistant
206
+ // calls it back, however long it was sent away for (T-27).
207
+ if (mode === 'assistant') {
208
+ callAssistantBack();
209
+ }
119
210
  setViewMode(mode);
120
211
  setFocusTrigger(prev => prev + 1);
121
212
  onViewModeChange?.(mode);
122
213
  }
123
- }, [onViewModeChange, sidebarDisabled]);
214
+ }, [onViewModeChange, sidebarDisabled, callAssistantBack]);
124
215
  // Detect whether the chat is mounted inside a parent that is intentionally
125
216
  // configured for docked side panels (Notebook example lays the chat out as a
126
217
  // flex sibling). Those hosts dock the panel in-flow. Plain content pages
@@ -272,6 +363,42 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
272
363
  // Reset animating after duration
273
364
  setTimeout(() => setIsAnimating(false), animationDuration);
274
365
  }, [isOpen, setIsOpen, onOpen, onClose, animationDuration]);
366
+ /*
367
+ * The assistant sent away (T-27): goodbye first, then the popup's round
368
+ * button in its place — which calls it back when it went for the page, and
369
+ * opens the chat when it went for the session or for good.
370
+ */
371
+ const dismissAssistant = useCallback((away) => {
372
+ if (isOpen) {
373
+ handleToggle();
374
+ }
375
+ keepAway(away);
376
+ setLeaving(true);
377
+ setTimeout(() => {
378
+ setLeaving(false);
379
+ setAssistantAway(away);
380
+ }, 600);
381
+ }, [isOpen, handleToggle]);
382
+ /*
383
+ * The conversation as the assistant's balloon: above the character, its
384
+ * right edge on the character's, and never off the screen.
385
+ */
386
+ const balloonPlace = () => {
387
+ const widthPx = typeof width === 'number' ? width : 400;
388
+ const heightPx = typeof height === 'number' ? height : 550;
389
+ if (stageDrag.position) {
390
+ return {
391
+ left: Math.max(8, stageDrag.position.left + ASSISTANT_SIZE - widthPx),
392
+ top: Math.max(8, stageDrag.position.top - heightPx - 16),
393
+ right: 'auto',
394
+ bottom: 'auto',
395
+ };
396
+ }
397
+ const corner = getPositionStyles();
398
+ return position.startsWith('bottom')
399
+ ? { ...corner, bottom: offset + ASSISTANT_SIZE + 16 }
400
+ : { ...corner, top: offset + ASSISTANT_SIZE + 16 };
401
+ };
275
402
  // Handle new chat
276
403
  const handleNewChat = useCallback(() => {
277
404
  if (useStoreMode) {
@@ -437,6 +564,11 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
437
564
  }
438
565
  : getPositionStyles()
439
566
  : {}),
567
+ ...(assistantShown
568
+ ? balloonPlace()
569
+ : assistantMode && !isMobile
570
+ ? getPositionStyles()
571
+ : {}),
440
572
  ...(viewMode === 'panel' && !isMobile
441
573
  ? {
442
574
  ...(panelInFlow || dockedInMount
@@ -468,7 +600,8 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
468
600
  ? `${popupWidth}px`
469
601
  : popupWidth
470
602
  : (viewMode === 'floating-small' ||
471
- viewMode === 'floating-draggable') &&
603
+ viewMode === 'floating-draggable' ||
604
+ viewMode === 'assistant') &&
472
605
  !isMobile
473
606
  ? typeof popupWidth === 'number'
474
607
  ? `${popupWidth}px`
@@ -487,7 +620,8 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
487
620
  : viewMode === 'floating' && !isMobile
488
621
  ? '100%'
489
622
  : (viewMode === 'floating-small' ||
490
- viewMode === 'floating-draggable') &&
623
+ viewMode === 'floating-draggable' ||
624
+ viewMode === 'assistant') &&
491
625
  !isMobile
492
626
  ? typeof popupHeight === 'number'
493
627
  ? `${popupHeight}px`
@@ -502,7 +636,11 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
502
636
  bg: 'canvas.default',
503
637
  border: '1px solid',
504
638
  borderColor: 'border.default',
505
- borderRadius: viewMode === 'panel' || isMobile ? 0 : '12px',
639
+ borderRadius: viewMode === 'panel' || isMobile
640
+ ? 0
641
+ : assistantMode
642
+ ? 'var(--theme-radius-bubble, 16px)'
643
+ : '12px',
506
644
  boxShadow: viewMode === 'panel' ? 'shadow.none' : 'shadow.extra-large',
507
645
  overflow: 'hidden',
508
646
  transform: viewMode === 'panel'
@@ -550,30 +688,56 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
550
688
  ...poweredByProps,
551
689
  }, renderToolResult: renderToolResult, description: description, onStateUpdate: onStateUpdate, onNewChat: onNewChat, suggestions: suggestions, submitOnSuggestionClick: submitOnSuggestionClick, hideMessagesAfterToolUI: hideMessagesAfterToolUI, avatarConfig: {
552
690
  showAvatars: true,
553
- }, placeholder: "Type a message...", backgroundColor: "canvas.subtle", frontendTools: frontendTools, showModelSelector: showModelSelector, availableModels: availableModels, showToolsMenu: showToolsMenu, showSkillsMenu: showSkillsMenu, showTokenUsage: showTokenUsage, showContextRing: showContextRing, runtimeId: runtimeId, historyEndpoint: historyEndpoint, historyAuthToken: historyAuthToken, pendingPrompt: pendingPrompt, showInformation: showInformation, onInformationClick: onInformationClick, onToolCallStart: onToolCallStart, onToolCallComplete: onToolCallComplete, showToolApprovalBanner: showToolApprovalBanner, pendingApprovals: pendingApprovals, onApproveApproval: onApproveApproval, onRejectApproval: onRejectApproval, ...panelProps, children: children })] }));
554
- return (_jsxs(_Fragment, { children: [showButton && !isOpen && (_jsx(Box, { sx: {
691
+ }, placeholder: "Type a message...", backgroundColor: "canvas.subtle", frontendTools: frontendTools, showModelSelector: showModelSelector, availableModels: availableModels, showToolsMenu: showToolsMenu, showSkillsMenu: showSkillsMenu, showTokenUsage: showTokenUsage, showContextRing: showContextRing, runtimeId: runtimeId, historyEndpoint: historyEndpoint, historyAuthToken: historyAuthToken, pendingPrompt: pendingPrompt, showInformation: showInformation, onInformationClick: onInformationClick, onToolCallStart: onToolCallStart, onToolCallComplete: onToolCallComplete, showToolApprovalBanner: showToolApprovalBanner, pendingApprovals: pendingApprovals, onApproveApproval: onApproveApproval, onRejectApproval: onRejectApproval, ...panelProps, onLoadingChange: handleLoadingChange, onDisplayItemsChange: handleDisplayItemsChange, children: children })] }));
692
+ /*
693
+ * The floating popup's button. Words not yet heard make it blink for the
694
+ * person's attention, until the chat is opened; hovered, they show in a
695
+ * balloon above it in place of the tooltip.
696
+ */
697
+ const chatButton = (_jsx(IconButton, { icon: buttonIcon ? buttonIcon : CommentDiscussionIcon, "aria-label": buttonTooltip, onClick: assistantMode && assistantAway === 'page'
698
+ ? callAssistantBack
699
+ : handleToggle, size: "large", sx: {
700
+ width: 56,
701
+ height: 56,
702
+ borderRadius: '50%',
703
+ bg: brandColor || 'accent.emphasis',
704
+ color: 'fg.onEmphasis',
705
+ boxShadow: 'shadow.large',
706
+ transition: 'transform 0.2s ease, box-shadow 0.2s ease',
707
+ transform: isHovered ? 'scale(1.1)' : 'scale(1)',
708
+ // Words not yet heard: the circle blinks for the person's
709
+ // attention, and stops once the chat is opened.
710
+ ...(unheard
711
+ ? {
712
+ animation: 'chatFloatingBlink 1.1s ease-in-out infinite',
713
+ '@keyframes chatFloatingBlink': {
714
+ '0%, 100%': {
715
+ filter: 'brightness(1)',
716
+ boxShadow: '0 0 0 0 rgba(0,0,0,0)',
717
+ },
718
+ '50%': {
719
+ filter: 'brightness(1.25)',
720
+ boxShadow: '0 0 0 6px var(--bgColor-accent-muted, rgba(84,174,255,0.4))',
721
+ },
722
+ },
723
+ '@media (prefers-reduced-motion: reduce)': { animation: 'none' },
724
+ }
725
+ : {}),
726
+ '&:hover': {
727
+ bg: brandColor || 'accent.emphasis',
728
+ boxShadow: 'shadow.extra-large',
729
+ },
730
+ } }));
731
+ return (_jsxs(_Fragment, { children: [assistantShown && (_jsx(AssistantStage, { character: assistantCharacter, state: assistantState, size: ASSISTANT_SIZE, place: stageDrag.position
732
+ ? { left: stageDrag.position.left, top: stageDrag.position.top }
733
+ : getPositionStyles(), stageRef: stageRef, onDragStart: stageDrag.onHandlePointerDown, open: isOpen, onToggle: handleToggle, balloon: assistantBalloon, insist: balloonInsists, onDismiss: dismissAssistant })), showButton && !isOpen && !assistantShown && (_jsx(Box, { sx: {
555
734
  ...getPositionStyles(),
556
735
  }, children: _jsxs(Box, { sx: {
557
736
  position: 'relative',
558
737
  display: 'inline-flex',
559
738
  // The glow's halos go behind the button, not behind the page.
560
739
  isolation: 'isolate',
561
- }, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [buttonGlow && _jsx(ButtonGlow, { color: brandColor }), _jsx(Tooltip, { text: `${buttonTooltip}${shortcutHint ? ` (${shortcutHint})` : ''}`, direction: position.includes('right') ? 'w' : 'e', children: _jsx(IconButton, { icon: buttonIcon
562
- ? buttonIcon
563
- : CommentDiscussionIcon, "aria-label": buttonTooltip, onClick: handleToggle, size: "large", sx: {
564
- width: 56,
565
- height: 56,
566
- borderRadius: '50%',
567
- bg: brandColor || 'accent.emphasis',
568
- color: 'fg.onEmphasis',
569
- boxShadow: 'shadow.large',
570
- transition: 'transform 0.2s ease, box-shadow 0.2s ease',
571
- transform: isHovered ? 'scale(1.1)' : 'scale(1)',
572
- '&:hover': {
573
- bg: brandColor || 'accent.emphasis',
574
- boxShadow: 'shadow.extra-large',
575
- },
576
- } }) }), messages.length > 0 && (_jsx(Box, { sx: {
740
+ }, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [buttonGlow && _jsx(ButtonGlow, { color: brandColor }), isHovered && saying ? (_jsx(SpeechBalloon, { text: saying.text, more: saying.more, onOpen: handleToggle, above: 64, side: position.startsWith('bottom') ? 'above' : 'below', align: position.includes('right') ? 'right' : 'left', tailAt: 28 })) : null, saying ? (chatButton) : (_jsx(Tooltip, { text: `${buttonTooltip}${shortcutHint ? ` (${shortcutHint})` : ''}`, direction: position.includes('right') ? 'w' : 'e', children: chatButton })), messages.length > 0 && (_jsx(Box, { sx: {
577
741
  position: 'absolute',
578
742
  top: 0,
579
743
  right: 0,
@@ -589,7 +753,7 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
589
753
  fontSize: 0,
590
754
  fontWeight: 'bold',
591
755
  pointerEvents: 'none',
592
- }, children: _jsx(Text, { sx: { fontSize: 0 }, children: messages.length > 99 ? '99+' : messages.length }) })), messages.length > 0 && (_jsx(Box, { sx: {
756
+ }, children: _jsx(Text, { sx: { fontSize: 0 }, children: messages.length > 99 ? '99+' : messages.length }) })), unheard && (_jsx(Box, { sx: {
593
757
  position: 'absolute',
594
758
  top: 0,
595
759
  left: 0,
@@ -610,6 +774,13 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
610
774
  opacity: 0,
611
775
  },
612
776
  },
777
+ // Still, as a ring held around the button, for a reader
778
+ // who asks the system for reduced motion (T-28).
779
+ '@media (prefers-reduced-motion: reduce)': {
780
+ animation: 'none',
781
+ transform: 'scale(1.15)',
782
+ opacity: 0.6,
783
+ },
613
784
  } }))] }) })), isMobile && isOpen && (_jsx(Box, { sx: {
614
785
  position: 'fixed',
615
786
  top: 0,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The floating assistant on the page (LOOP T-21, T-22, T-27): a character in
3
+ * a corner, acting out what the application is doing, with a balloon that
4
+ * says a word while the conversation is closed. Clicked, it opens the
5
+ * conversation; dragged, it moves, and the conversation follows; sent away,
6
+ * it says goodbye and leaves a small way back.
7
+ *
8
+ * Its motions are one set for every character, by the parts each drawing
9
+ * names (`assistant-body`, `-pupils`, `-lids`, `-mouth`), and nothing moves
10
+ * for a reader who asks the system for reduced motion.
11
+ *
12
+ * @module chat/assistant/AssistantStage
13
+ */
14
+ import type { JSX, RefObject } from 'react';
15
+ import { type AssistantCharacter } from './characters';
16
+ import type { AssistantCharacterData } from './formats/types';
17
+ import type { AssistantAway, AssistantState } from './state';
18
+ /**
19
+ * Where the balloon goes so that it stays inside the window (T-23): toward
20
+ * the middle of the page from whichever half the character stands in, and
21
+ * below it when it stands too near the top.
22
+ */
23
+ export declare function balloonSide(place: {
24
+ left?: unknown;
25
+ top?: unknown;
26
+ right?: unknown;
27
+ bottom?: unknown;
28
+ }, viewport?: {
29
+ width: number;
30
+ height: number;
31
+ }): {
32
+ side: 'above' | 'below';
33
+ align: 'left' | 'right';
34
+ };
35
+ export interface AssistantStageProps {
36
+ /** The character: one Datalayer ships, by id (T-25), or one loaded from a file (T-26). */
37
+ character: string | AssistantCharacter | AssistantCharacterData;
38
+ /** What it acts out (T-22). */
39
+ state: AssistantState;
40
+ /** Its size, in pixels. */
41
+ size?: number;
42
+ /** Where it sits once moved, or the corner it starts in. */
43
+ place: {
44
+ left: number;
45
+ top: number;
46
+ } | React.CSSProperties;
47
+ /** The element the drag measures. */
48
+ stageRef: RefObject<HTMLDivElement | null>;
49
+ /** Starts a drag. */
50
+ onDragStart: (event: React.PointerEvent<HTMLElement>) => void;
51
+ /** The conversation is open: the balloon above is the chat itself. */
52
+ open: boolean;
53
+ /** Open or close the conversation. */
54
+ onToggle: () => void;
55
+ /**
56
+ * What the balloon says while the conversation is closed: the agent's
57
+ * newest words, as the Office Assistant said them (T-23), or a welcome.
58
+ * `more` adds *Open the conversation* for the rest.
59
+ */
60
+ balloon?: {
61
+ text: string;
62
+ more?: boolean;
63
+ };
64
+ /** Show the balloon without being hovered: something new to say. */
65
+ insist?: boolean;
66
+ /** Send it away (T-27): for the page, for the session, or for good. */
67
+ onDismiss: (away: AssistantAway) => void;
68
+ }
69
+ export declare function AssistantStage({ character, state, size, place, stageRef, onDragStart, open, onToggle, balloon, insist, onDismiss, }: AssistantStageProps): JSX.Element;
70
+ export default AssistantStage;
@@ -0,0 +1,192 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { ActionList, ActionMenu, IconButton } from '@primer/react';
4
+ import { Box } from '@datalayer/primer-addons';
5
+ import { XIcon } from '@primer/octicons-react';
6
+ import { assistantCharacter } from './characters';
7
+ import { SpeechBalloon } from './SpeechBalloon';
8
+ import { SpriteCharacter } from './SpriteCharacter';
9
+ /** The motions, by the state the stage is in. */
10
+ const MOTIONS = {
11
+ '@keyframes assistantFloat': {
12
+ '0%, 100%': { transform: 'translateY(0)' },
13
+ '50%': { transform: 'translateY(-3px)' },
14
+ },
15
+ '@keyframes assistantBlink': {
16
+ '0%, 94%, 100%': { transform: 'scaleY(0)' },
17
+ '97%': { transform: 'scaleY(1)' },
18
+ },
19
+ '@keyframes assistantTilt': {
20
+ '0%, 100%': { transform: 'rotate(-5deg)' },
21
+ '50%': { transform: 'rotate(-8deg) translateY(-2px)' },
22
+ },
23
+ '@keyframes assistantWork': {
24
+ '0%, 100%': { transform: 'rotate(-3deg) translateY(0)' },
25
+ '50%': { transform: 'rotate(3deg) translateY(-4px)' },
26
+ },
27
+ '@keyframes assistantAttention': {
28
+ '0%, 100%': { transform: 'scale(1) rotate(0)' },
29
+ '20%': { transform: 'scale(1.08) rotate(-6deg)' },
30
+ '40%': { transform: 'scale(1.08) rotate(6deg)' },
31
+ '60%': { transform: 'scale(1)' },
32
+ },
33
+ '@keyframes assistantHop': {
34
+ '0%, 100%': { transform: 'translateY(0)' },
35
+ '30%': { transform: 'translateY(-14px)' },
36
+ '55%': { transform: 'translateY(0)' },
37
+ '75%': { transform: 'translateY(-6px)' },
38
+ },
39
+ '@keyframes assistantTalk': {
40
+ '0%, 100%': { transform: 'scaleY(1)' },
41
+ '50%': { transform: 'scaleY(2.2)' },
42
+ },
43
+ '@keyframes assistantLeave': {
44
+ from: { transform: 'scale(1)', opacity: 1 },
45
+ to: { transform: 'scale(0.2) translateY(20px)', opacity: 0 },
46
+ },
47
+ };
48
+ const BODY = '& .assistant-body';
49
+ /** How long it may be sent away for, in the menu's words (T-27). */
50
+ const AWAY_CHOICES = [
51
+ { away: 'page', label: 'Hide for now' },
52
+ { away: 'session', label: 'Hide for this session' },
53
+ { away: 'always', label: 'Don\u2019t show again' },
54
+ ];
55
+ /** Room the balloon needs above the character before it goes below. */
56
+ const BALLOON_ROOM = 200;
57
+ /**
58
+ * Where the balloon goes so that it stays inside the window (T-23): toward
59
+ * the middle of the page from whichever half the character stands in, and
60
+ * below it when it stands too near the top.
61
+ */
62
+ export function balloonSide(place, viewport = {
63
+ width: typeof window === 'undefined' ? 1280 : window.innerWidth,
64
+ height: typeof window === 'undefined' ? 800 : window.innerHeight,
65
+ }) {
66
+ const left = typeof place.left === 'number' ? place.left : undefined;
67
+ const top = typeof place.top === 'number' ? place.top : undefined;
68
+ const align = left !== undefined
69
+ ? left < viewport.width / 2
70
+ ? 'left'
71
+ : 'right'
72
+ : place.left !== undefined && place.right === undefined
73
+ ? 'left'
74
+ : 'right';
75
+ const side = top !== undefined
76
+ ? top < BALLOON_ROOM
77
+ ? 'below'
78
+ : 'above'
79
+ : place.top !== undefined && place.bottom === undefined
80
+ ? 'below'
81
+ : 'above';
82
+ return { side, align };
83
+ }
84
+ export function AssistantStage({ character, state, size = 88, place, stageRef, onDragStart, open, onToggle, balloon, insist = false, onDismiss, }) {
85
+ // A shipped one by id, a drawing contributed by a plugin (T-24), or a
86
+ // character read from a file (T-26).
87
+ const shipped = typeof character === 'string'
88
+ ? assistantCharacter(character)
89
+ : 'Drawing' in character
90
+ ? character
91
+ : undefined;
92
+ const name = shipped
93
+ ? shipped.name
94
+ : character.name;
95
+ const [hovered, setHovered] = useState(false);
96
+ const [menuOpen, setMenuOpen] = useState(false);
97
+ // Where the press began: a press that moves is a drag, not a click.
98
+ const pressedAt = useRef(null);
99
+ const showBalloon = !open && !!balloon && (hovered || insist);
100
+ return (_jsxs(Box, { ref: stageRef, "data-assistant-state": state, sx: {
101
+ position: 'fixed',
102
+ zIndex: 1002,
103
+ ...place,
104
+ width: size,
105
+ height: size,
106
+ ...MOTIONS,
107
+ '& .assistant-lids ellipse': {
108
+ transformBox: 'fill-box',
109
+ transformOrigin: 'top',
110
+ transform: 'scaleY(0)',
111
+ animation: 'assistantBlink 5.2s ease-in-out infinite',
112
+ },
113
+ [BODY]: {
114
+ transformBox: 'fill-box',
115
+ transformOrigin: '50% 100%',
116
+ },
117
+ '& .assistant-pupils': {
118
+ transition: 'transform var(--theme-motion-status, 120ms) var(--theme-motion-easing, ease)',
119
+ },
120
+ '& .assistant-mouth': {
121
+ transformBox: 'fill-box',
122
+ transformOrigin: 'center',
123
+ },
124
+ '&[data-assistant-state="idle"] .assistant-body': {
125
+ animation: 'assistantFloat 3.2s ease-in-out infinite',
126
+ },
127
+ '&[data-assistant-state="thinking"] .assistant-body': {
128
+ animation: 'assistantTilt 2.4s ease-in-out infinite',
129
+ },
130
+ '&[data-assistant-state="thinking"] .assistant-pupils': {
131
+ transform: 'translate(-1px, -3px)',
132
+ },
133
+ '&[data-assistant-state="working"] .assistant-body': {
134
+ animation: 'assistantWork 0.7s ease-in-out infinite',
135
+ },
136
+ '&[data-assistant-state="working"] .assistant-pupils': {
137
+ transform: 'translate(2px, 2px)',
138
+ },
139
+ '&[data-assistant-state="waiting"] .assistant-body': {
140
+ animation: 'assistantAttention 1.4s ease-in-out infinite',
141
+ },
142
+ '&[data-assistant-state="greeting"] .assistant-body': {
143
+ animation: 'assistantHop 0.9s ease-out 2',
144
+ },
145
+ '&[data-assistant-state="speaking"] .assistant-mouth': {
146
+ animation: 'assistantTalk 0.24s ease-in-out infinite',
147
+ },
148
+ '&[data-assistant-state="speaking"] .assistant-body': {
149
+ animation: 'assistantFloat 1.6s ease-in-out infinite',
150
+ },
151
+ '&[data-assistant-state="goodbye"] .assistant-body': {
152
+ animation: 'assistantLeave 0.6s ease-in forwards',
153
+ },
154
+ // One still frame per state for a reader who asks for no motion.
155
+ '@media (prefers-reduced-motion: reduce)': {
156
+ '& *': { animation: 'none !important' },
157
+ },
158
+ }, 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) => {
159
+ pressedAt.current = { x: event.clientX, y: event.clientY };
160
+ onDragStart(event);
161
+ }, onClick: (event) => {
162
+ const from = pressedAt.current;
163
+ pressedAt.current = null;
164
+ if (from &&
165
+ Math.hypot(event.clientX - from.x, event.clientY - from.y) > 4) {
166
+ return;
167
+ }
168
+ onToggle();
169
+ }, sx: {
170
+ display: 'block',
171
+ width: size,
172
+ height: size,
173
+ p: 0,
174
+ border: 0,
175
+ bg: 'transparent',
176
+ cursor: 'grab',
177
+ touchAction: 'none',
178
+ filter: 'drop-shadow(0 6px 10px rgba(0, 0, 0, 0.18))',
179
+ '&:active': { cursor: 'grabbing' },
180
+ '&:focus-visible': {
181
+ outline: '2px solid',
182
+ outlineColor: 'var(--focus-outlineColor, var(--fgColor-accent))',
183
+ outlineOffset: 2,
184
+ borderRadius: '50%',
185
+ },
186
+ }, children: shipped ? (_jsx(shipped.Drawing, { size: size })) : (_jsx(SpriteCharacter, { character: character, state: state, size: size })) }), (hovered || menuOpen) && state !== 'goodbye' && (_jsx(Box, { sx: { position: 'absolute', top: -6, right: -6 }, children: _jsxs(ActionMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(ActionMenu.Anchor, { children: _jsx(IconButton, { icon: XIcon, "aria-label": `Send ${name} away`, size: "small", variant: "invisible", "data-assistant-dismiss": "", sx: {
187
+ bg: 'canvas.default',
188
+ borderRadius: '50%',
189
+ boxShadow: 'shadow.small',
190
+ } }) }), _jsx(ActionMenu.Overlay, { width: "auto", children: _jsx(ActionList, { children: AWAY_CHOICES.map(choice => (_jsx(ActionList.Item, { "data-assistant-away": choice.away, onSelect: () => onDismiss(choice.away), children: choice.label }, choice.away))) }) })] }) }))] }));
191
+ }
192
+ export default AssistantStage;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A speech balloon over something that stands for the agent — the floating
3
+ * assistant's character, or the floating popup's button while the chat is
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.
6
+ *
7
+ * @module chat/assistant/SpeechBalloon
8
+ */
9
+ import type { JSX } from 'react';
10
+ export interface SpeechBalloonProps {
11
+ text: string;
12
+ /** There is more than the balloon holds. */
13
+ more?: boolean;
14
+ /** Opens the conversation. */
15
+ onOpen: () => void;
16
+ /** How far from what it stands over, in pixels: above it, or below. */
17
+ above: number;
18
+ /** Above what speaks (the default), or below it when it sits at the top. */
19
+ side?: 'above' | 'below';
20
+ /** Which edge it is aligned to, toward the page's inside. */
21
+ align: 'left' | 'right';
22
+ /** Where the tail points, from that edge, in pixels. */
23
+ tailAt: number;
24
+ }
25
+ export declare function SpeechBalloon({ text, more, onOpen, above, side, align, tailAt, }: SpeechBalloonProps): JSX.Element;
26
+ export default SpeechBalloon;