@fiodos/web-core 0.1.9 → 0.1.11

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 (37) hide show
  1. package/dist/cjs/api/backendClient.js +20 -0
  2. package/dist/cjs/config/types.d.ts +18 -0
  3. package/dist/cjs/config/types.js +6 -1
  4. package/dist/cjs/controller/AgentController.d.ts +84 -1
  5. package/dist/cjs/controller/AgentController.js +515 -75
  6. package/dist/cjs/index.d.ts +3 -3
  7. package/dist/cjs/index.js +2 -1
  8. package/dist/cjs/orb/mountOrb.d.ts +6 -0
  9. package/dist/cjs/orb/mountOrb.js +307 -53
  10. package/dist/cjs/orb/orbView.d.ts +2 -0
  11. package/dist/cjs/orb/publishedConfig.d.ts +18 -0
  12. package/dist/cjs/orb/publishedConfig.js +6 -0
  13. package/dist/cjs/speech/bubbleDictation.d.ts +11 -0
  14. package/dist/cjs/speech/bubbleDictation.js +88 -0
  15. package/dist/cjs/ui/messages.d.ts +15 -0
  16. package/dist/cjs/ui/messages.js +12 -0
  17. package/dist/cjs/version.d.ts +1 -1
  18. package/dist/cjs/version.js +1 -1
  19. package/dist/esm/api/backendClient.js +20 -0
  20. package/dist/esm/config/types.d.ts +18 -0
  21. package/dist/esm/config/types.js +5 -0
  22. package/dist/esm/controller/AgentController.d.ts +84 -1
  23. package/dist/esm/controller/AgentController.js +517 -77
  24. package/dist/esm/index.d.ts +3 -3
  25. package/dist/esm/index.js +1 -1
  26. package/dist/esm/orb/mountOrb.d.ts +6 -0
  27. package/dist/esm/orb/mountOrb.js +308 -54
  28. package/dist/esm/orb/orbView.d.ts +2 -0
  29. package/dist/esm/orb/publishedConfig.d.ts +18 -0
  30. package/dist/esm/orb/publishedConfig.js +6 -0
  31. package/dist/esm/speech/bubbleDictation.d.ts +11 -0
  32. package/dist/esm/speech/bubbleDictation.js +84 -0
  33. package/dist/esm/ui/messages.d.ts +15 -0
  34. package/dist/esm/ui/messages.js +12 -0
  35. package/dist/esm/version.d.ts +1 -1
  36. package/dist/esm/version.js +1 -1
  37. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  */
10
10
  export { SDK_VERSION, SDK_PACKAGE } from './version.js';
11
11
  export { AgentController } from './controller/AgentController.js';
12
- export type { AgentState, AgentExchange } from './controller/AgentController.js';
12
+ export type { AgentState, AgentExchange, AgentActivity } from './controller/AgentController.js';
13
13
  export { createFiodosAgent } from './dropin/createFiodosAgent.js';
14
14
  export type { CreateFiodosAgentOptions, FiodosAgentHandle } from './dropin/createFiodosAgent.js';
15
15
  export { DEFAULT_WEB_API_URL, fetchClientManifest, sendOrbSeen, } from './api/clientBootstrap.js';
@@ -38,8 +38,8 @@ export { createScreenContextStore } from './context/screenContextStore.js';
38
38
  export type { ScreenContextStore, AgentScreenSnapshot } from './context/screenContextStore.js';
39
39
  export { createSpeechSession } from './speech/speechSession.js';
40
40
  export type { SpeechSession, SpeechSessionOptions, SpeechSessionState, SpeechSessionSnapshot, } from './speech/speechSession.js';
41
- export { DEFAULT_AGENT_TIMINGS } from './config/types.js';
42
- export type { FiodosWebAgentConfig, AgentPhase, AgentTimings, TurnGateVerdict, } from './config/types.js';
41
+ export { DEFAULT_AGENT_TIMINGS, DEFAULT_AGENT_CHAINING } from './config/types.js';
42
+ export type { FiodosWebAgentConfig, AgentPhase, AgentTimings, AgentChaining, TurnGateVerdict, } from './config/types.js';
43
43
  export { resolveUiMessages } from './ui/messages.js';
44
44
  export type { WebUiMessages, ResolveUiMessagesOptions } from './ui/messages.js';
45
45
  export { decideAction, classifyPendingReply } from './core/turnEngine.js';
package/dist/esm/index.js CHANGED
@@ -34,7 +34,7 @@ export { createScreenContextStore } from './context/screenContextStore.js';
34
34
  // ── Speech session ──────────────────────────────────────────────────────────────
35
35
  export { createSpeechSession } from './speech/speechSession.js';
36
36
  // ── Config / i18n ─────────────────────────────────────────────────────────────
37
- export { DEFAULT_AGENT_TIMINGS } from './config/types.js';
37
+ export { DEFAULT_AGENT_TIMINGS, DEFAULT_AGENT_CHAINING } from './config/types.js';
38
38
  export { resolveUiMessages } from './ui/messages.js';
39
39
  // ── Decision engine (shared, framework-free; advanced use / testing) ──────────
40
40
  export { decideAction, classifyPendingReply } from './core/turnEngine.js';
@@ -17,6 +17,12 @@ export interface MountOrbOptions {
17
17
  appearancePollMs?: number;
18
18
  /** Honour the dashboard's published screen position. Default: true. */
19
19
  usePublishedPosition?: boolean;
20
+ /**
21
+ * Internal-profile orbs only render on development hosts (localhost, LAN
22
+ * IPs, *.local). Set true to assert this page IS internal software (e.g. a
23
+ * hosted admin panel) so an internal orb may render here. Default: false.
24
+ */
25
+ allowInternalOrb?: boolean;
20
26
  }
21
27
  export interface MountedOrb {
22
28
  unmount(): void;
@@ -16,9 +16,10 @@
16
16
  * - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
17
17
  * deliberate tap for payment-grade; loose "yes" never resolves strict).
18
18
  */
19
- import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, resolveBubbleTheme, resolveBubblePadding, resolveBubbleShapeStyle, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
19
+ import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
20
20
  import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
21
21
  import { watchPublishedConfig } from './publishedConfig.js';
22
+ import { createBubbleDictation, scrollTextInputToEnd } from '../speech/bubbleDictation.js';
22
23
  const STYLE_ID = 'fyodos-orb-styles';
23
24
  const CSS = `
24
25
  .fyodos-orb-root{position:fixed;display:inline-flex;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;touch-action:none;}
@@ -27,20 +28,32 @@ const CSS = `
27
28
  .fyodos-orb-btn:focus-visible{outline:2px solid #8ab6ff;outline-offset:4px;border-radius:50%;}
28
29
  .fyodos-orb-anchor{position:relative;display:inline-flex;}
29
30
  .fyodos-bubble{position:absolute;display:flex;flex-direction:column;width:min(320px,calc(100vw - 48px));max-height:340px;overflow:hidden;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,0.28);border:1px solid rgba(160,180,220,0.22);animation:fyodos-fade-in .14s ease-out;}
30
- .fyodos-bubble-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;border:none;background:transparent;font-size:18px;line-height:18px;width:28px;height:28px;min-width:28px;min-height:28px;margin-top:-10px;margin-right:-10px;cursor:pointer;padding:0;opacity:.75;}
31
+ .fyodos-bubble-close{position:absolute;top:4px;right:6px;z-index:1;display:flex;align-items:center;justify-content:center;border:none;background:transparent;font-size:18px;line-height:18px;width:28px;height:28px;cursor:pointer;padding:0;opacity:.75;}
31
32
  .fyodos-bubble-user-row{display:flex;flex-direction:row;align-items:flex-start;gap:6px;}
32
- .fyodos-bubble-scroll{overflow-y:auto;padding:12px 14px 4px;display:flex;flex-direction:column;gap:6px;}
33
+ .fyodos-bubble-scroll{overflow-y:auto;padding:12px 14px 4px;display:flex;flex-direction:column;gap:6px;min-height:18px;scrollbar-width:none;-ms-overflow-style:none;}
34
+ .fyodos-bubble-scroll::-webkit-scrollbar{display:none;width:0;height:0;}
35
+ .fyodos-bubble-loader{display:flex;justify-content:center;align-items:center;padding:2px 0 6px;flex-shrink:0;}
36
+ .fyodos-bubble-loader span{width:14px;height:14px;border-radius:50%;border:2px solid rgba(148,163,184,0.35);border-top-color:rgba(148,163,184,0.9);animation:fy-spin .7s linear infinite;}
37
+ .fyodos-bubble-exchanges{display:flex;flex-direction:column;gap:12px;}
38
+ .fyodos-bubble-exchange{display:flex;flex-direction:column;gap:6px;}
39
+ .fyodos-bubble-exchange:first-child .fyodos-bubble-user{padding-right:22px;}
33
40
  .fyodos-bubble-user{flex:1;min-width:0;font-size:13px;line-height:18px;opacity:.75;}
34
41
  .fyodos-bubble-reply{font-size:15px;line-height:21px;white-space:pre-wrap;}
35
42
  .fyodos-bubble-busy{display:flex;gap:5px;padding:8px 14px;align-items:center;}
36
43
  .fyodos-bubble-busy span{width:6px;height:6px;border-radius:50%;opacity:.5;animation:fyodos-blink 1s infinite;}
37
44
  .fyodos-bubble-busy span:nth-child(2){animation-delay:.18s;}
38
45
  .fyodos-bubble-busy span:nth-child(3){animation-delay:.36s;}
46
+ .fyodos-busy-step{margin-right:6px;font-size:12.5px;line-height:18px;font-style:italic;animation:fyodos-fade-in .28s ease-out;}
39
47
  .fyodos-bubble-row{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;border-top:1px solid rgba(160,180,220,0.18);}
40
- .fyodos-bubble-row input{flex:1;border:1px solid rgba(15,23,42,0.12);outline:none;border-radius:10px;padding:9px 11px;font-size:15px;}
48
+ .fyodos-bubble-row input{flex:1;min-width:0;border:none;outline:none;border-radius:10px;padding:9px 11px;font-size:15px;}
49
+ .fyodos-bubble-row input::placeholder{color:var(--fyodos-input-placeholder,#94a3b8);opacity:1;}
50
+ .fyodos-bubble-mic{appearance:none;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-width:32px;padding:0;border:1px solid rgba(148,163,184,0.4);border-radius:999px;background:rgba(255,255,255,0.06);color:inherit;cursor:pointer;}
41
51
  .fyodos-bubble-row button{appearance:none;border:none;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-width:32px;padding:0;font-size:17px;font-weight:700;line-height:1;color:#000;cursor:pointer;}
42
- .fyodos-thought{position:absolute;appearance:none;display:inline-block;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;line-height:1.25;font-weight:500;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.22);animation:fyodos-thought-in .18s ease-out;}
52
+ .fyodos-thought{position:absolute;appearance:none;box-sizing:border-box;display:flex;align-items:center;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;text-align:left;line-height:1.25;font-weight:500;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.22);animation:fyodos-thought-in .18s ease-out;}
43
53
  @keyframes fyodos-thought-in{from{opacity:0;transform:translateY(4px) scale(0.96)}to{opacity:1;transform:none}}
54
+ @keyframes fyodos-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
55
+ .fyodos-thought-track{display:flex;flex-shrink:0;width:max-content;will-change:transform;}
56
+ .fyodos-thought-track>span{flex-shrink:0;padding-right:48px;white-space:nowrap;}
44
57
  @keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
45
58
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
46
59
  @keyframes fy-spin{to{transform:rotate(360deg)}}
@@ -124,7 +137,7 @@ export function mountOrb(controller, options = {}) {
124
137
  }
125
138
  // Subtle haptic feedback on every relevant orb interaction (silent no-op on
126
139
  // browsers without the Vibration API — desktop, iOS Safari).
127
- const haptics = createWebOrbHaptics();
140
+ const haptics = createWebOrbHaptics(() => effectiveAppearance().hapticIntensity);
128
141
  // ── Effective appearance: defaults ← published ← explicit option overrides ──
129
142
  let publishedAppearance = null;
130
143
  let chipTheme = null;
@@ -134,6 +147,9 @@ export function mountOrb(controller, options = {}) {
134
147
  // Dashboard VOICE switch. Default true (voice on). When false the orb never
135
148
  // listens/speaks: a tap opens the keyboard directly and text turns are silent.
136
149
  let voiceInputEnabled = true;
150
+ // Dashboard "thinking steps" switch. When true the busy row walks through
151
+ // what the agent is doing ("Analyzing your request…") instead of only dots.
152
+ let thinkingStepsEnabled = false;
137
153
  let currentRoute = null;
138
154
  let screenPosition = options.corner
139
155
  ? cornerToPosition(options.corner)
@@ -167,19 +183,32 @@ export function mountOrb(controller, options = {}) {
167
183
  let chipEl = null;
168
184
  // Thought bubble: a short, screen-aware CTA beside the orb. Created once and
169
185
  // shown/hidden by syncThoughtBubble; tapping it opens the keyboard.
170
- const thoughtBubbleEl = document.createElement('button');
171
- thoughtBubbleEl.type = 'button';
186
+ const thoughtBubbleEl = document.createElement('div');
172
187
  thoughtBubbleEl.className = 'fyodos-thought';
188
+ thoughtBubbleEl.setAttribute('role', 'button');
189
+ thoughtBubbleEl.tabIndex = 0;
173
190
  thoughtBubbleEl.style.display = 'none';
174
191
  thoughtBubbleEl.addEventListener('click', () => {
175
192
  haptics.trigger('orbTap');
176
193
  controller.primeAudio();
177
194
  if (enableTextInput) {
178
- openTextPanel();
195
+ const prompt = resolveCurrentBubblePrompt();
196
+ openTextPanel(prompt
197
+ ? bubblePromptToAffirmative(prompt, {
198
+ locale: controller.config.locale,
199
+ messages: controller.messages,
200
+ })
201
+ : undefined);
179
202
  return;
180
203
  }
181
204
  void controller.toggleListening();
182
205
  });
206
+ thoughtBubbleEl.addEventListener('keydown', (event) => {
207
+ if (event.key === 'Enter' || event.key === ' ') {
208
+ event.preventDefault();
209
+ thoughtBubbleEl.click();
210
+ }
211
+ });
183
212
  anchor.appendChild(thoughtBubbleEl);
184
213
  container.appendChild(root);
185
214
  // ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
@@ -369,35 +398,168 @@ export function mountOrb(controller, options = {}) {
369
398
  }
370
399
  const exchangeScroll = document.createElement('div');
371
400
  exchangeScroll.className = 'fyodos-bubble-scroll';
372
- const userRow = document.createElement('div');
373
- userRow.className = 'fyodos-bubble-user-row';
374
- const userLine = document.createElement('div');
375
- userLine.className = 'fyodos-bubble-user';
401
+ // Close button overlaid at the bubble corner — in line with the first message,
402
+ // exactly like the dashboard preview (no extra header row/space).
376
403
  const closeBtn = document.createElement('button');
377
404
  closeBtn.type = 'button';
378
405
  closeBtn.className = 'fyodos-bubble-close';
379
406
  closeBtn.textContent = '×';
380
407
  closeBtn.setAttribute('aria-label', 'Close');
381
408
  closeBtn.addEventListener('click', () => setBubble(false));
382
- userRow.append(userLine, closeBtn);
383
- const replyLine = document.createElement('div');
384
- replyLine.className = 'fyodos-bubble-reply';
385
- exchangeScroll.append(userRow, replyLine);
409
+ // Loading row shown at the top while an older page of the session history
410
+ // streams in (scroll-to-top pagination).
411
+ const loaderRow = document.createElement('div');
412
+ loaderRow.className = 'fyodos-bubble-loader';
413
+ loaderRow.append(document.createElement('span'));
414
+ loaderRow.style.display = 'none';
415
+ const exchangeList = document.createElement('div');
416
+ exchangeList.className = 'fyodos-bubble-exchanges';
417
+ exchangeScroll.append(loaderRow, exchangeList);
418
+ // Signature of the rendered list so state emits that do not change the
419
+ // transcript (volume, phase…) never re-render it nor fight the user's scroll.
420
+ let renderedSignature = '';
421
+ function renderBubbleExchanges(exchanges) {
422
+ const signature = exchanges.map((ex) => `${ex.userText}\u0000${ex.replyText}`).join('\u0001');
423
+ if (signature === renderedSignature)
424
+ return;
425
+ // Prepend (older page loaded): the previous tail is intact at the end.
426
+ // Keep the user's reading position anchored instead of jumping to bottom.
427
+ const prepended = renderedSignature.length > 0 &&
428
+ signature.length > renderedSignature.length &&
429
+ signature.endsWith(renderedSignature);
430
+ const fromBottom = exchangeScroll.scrollHeight - exchangeScroll.scrollTop;
431
+ renderedSignature = signature;
432
+ exchangeList.replaceChildren();
433
+ for (const ex of exchanges) {
434
+ const block = document.createElement('div');
435
+ block.className = 'fyodos-bubble-exchange';
436
+ const user = document.createElement('div');
437
+ user.className = 'fyodos-bubble-user';
438
+ user.textContent = ex.userText;
439
+ block.append(user);
440
+ if (ex.replyText) {
441
+ const reply = document.createElement('div');
442
+ reply.className = 'fyodos-bubble-reply';
443
+ reply.textContent = ex.replyText;
444
+ block.append(reply);
445
+ }
446
+ exchangeList.append(block);
447
+ }
448
+ exchangeScroll.scrollTop = prepended
449
+ ? exchangeScroll.scrollHeight - fromBottom
450
+ : exchangeScroll.scrollHeight;
451
+ }
452
+ // Scroll-to-top pagination: reaching the top with older messages available
453
+ // pages the next block in (the controller shows the loader while it mounts).
454
+ exchangeScroll.addEventListener('scroll', () => {
455
+ if (!bubbleOpen || exchangeScroll.scrollTop > 12)
456
+ return;
457
+ const state = controller.getState();
458
+ if (state.hasOlderExchanges && !state.loadingOlderExchanges) {
459
+ controller.loadOlderExchanges();
460
+ }
461
+ });
386
462
  const busyRow = document.createElement('div');
387
463
  busyRow.className = 'fyodos-bubble-busy';
464
+ // REAL live activity FIRST in the row — flush left, aligned with the
465
+ // messages above (an <em> so the dot styling, which targets the spans,
466
+ // never applies to it). Hidden unless the dashboard advanced option is on.
467
+ const busyStep = document.createElement('em');
468
+ busyStep.className = 'fyodos-busy-step';
469
+ busyStep.style.display = 'none';
470
+ busyRow.appendChild(busyStep);
388
471
  busyRow.append(document.createElement('span'), document.createElement('span'), document.createElement('span'));
472
+ // ── REAL activity line (dashboard advanced option) ─────────────────────────
473
+ // Replaces the old canned walk-through: the controller reports what the
474
+ // agent is ACTUALLY doing (thinking / navigating / running an action /
475
+ // waiting for confirmation) and the line updates only on real changes.
476
+ let lastActivityText = null;
477
+ function activityText(state) {
478
+ if (!thinkingStepsEnabled || !state.liveActivity)
479
+ return null;
480
+ const { kind, label } = state.liveActivity;
481
+ switch (kind) {
482
+ case 'navigating':
483
+ return ui.activityNavigating.replace('{label}', label ?? '');
484
+ case 'executing':
485
+ return ui.activityExecuting.replace('{label}', label ?? '');
486
+ case 'waiting_confirmation':
487
+ return ui.activityWaitingConfirmation;
488
+ default:
489
+ return ui.activityThinking;
490
+ }
491
+ }
492
+ function syncActivityStep(show, state) {
493
+ const text = show ? activityText(state) : null;
494
+ if (text === lastActivityText)
495
+ return;
496
+ lastActivityText = text;
497
+ if (!text) {
498
+ busyStep.textContent = '';
499
+ busyStep.style.display = 'none';
500
+ return;
501
+ }
502
+ busyStep.style.display = '';
503
+ busyStep.textContent = text;
504
+ // Restart the fade-in for each new real step.
505
+ busyStep.style.animation = 'none';
506
+ void busyStep.offsetWidth;
507
+ busyStep.style.animation = '';
508
+ }
389
509
  const inputRow = document.createElement('div');
390
510
  inputRow.className = 'fyodos-bubble-row';
391
511
  const textInput = document.createElement('input');
392
512
  textInput.type = 'text';
393
513
  textInput.placeholder = ui.textPlaceholder;
514
+ const micBtn = document.createElement('button');
515
+ micBtn.type = 'button';
516
+ micBtn.className = 'fyodos-bubble-mic';
517
+ micBtn.innerHTML =
518
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"></rect><path d="M5 11a7 7 0 0014 0 M12 18v3"></path></svg>';
519
+ micBtn.style.display = 'none';
394
520
  const sendBtn = document.createElement('button');
395
521
  // Unified send affordance: an up-arrow glyph on the accent square (parity
396
522
  // with the dashboard preview and every native SDK), not a text label.
397
523
  sendBtn.textContent = '↑';
398
524
  sendBtn.setAttribute('aria-label', ui.sendLabel);
399
- inputRow.append(textInput, sendBtn);
400
- bubble.append(exchangeScroll, busyRow, inputRow);
525
+ inputRow.append(textInput, micBtn, sendBtn);
526
+ const bubbleDictation = createBubbleDictation(controller.config.locale);
527
+ if (bubbleDictation.isSupported()) {
528
+ micBtn.style.display = 'flex';
529
+ micBtn.setAttribute('aria-label', ui.micLabel);
530
+ micBtn.title = ui.micLabel;
531
+ micBtn.addEventListener('click', () => {
532
+ if (controller.getState().isBusy)
533
+ return;
534
+ bubbleDictation.toggle(() => textInput.value, (next) => {
535
+ textInput.value = next;
536
+ scrollTextInputToEnd(textInput);
537
+ });
538
+ syncMicButton();
539
+ });
540
+ }
541
+ function syncMicButton() {
542
+ const accent = modalTheme?.primaryButtonColor ?? effectiveAppearance().accentColor;
543
+ const sendRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
544
+ const bodyColor = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) }.bodyColor;
545
+ const active = bubbleDictation.isListening();
546
+ micBtn.classList.toggle('is-listening', active);
547
+ if (active) {
548
+ micBtn.style.background = accent;
549
+ micBtn.style.border = 'none';
550
+ micBtn.style.color = '#000';
551
+ micBtn.style.borderRadius = sendRadius;
552
+ }
553
+ else {
554
+ micBtn.style.background = 'rgba(255,255,255,0.06)';
555
+ micBtn.style.border = '1px solid rgba(148,163,184,0.4)';
556
+ micBtn.style.color = bodyColor;
557
+ micBtn.style.borderRadius = '999px';
558
+ }
559
+ micBtn.setAttribute('aria-label', active ? ui.micStopLabel : ui.micLabel);
560
+ micBtn.title = active ? ui.micStopLabel : ui.micLabel;
561
+ }
562
+ bubble.append(closeBtn, exchangeScroll, busyRow, inputRow);
401
563
  anchor.appendChild(bubble);
402
564
  // Initial placement (after the bubble exists, so applyDeployment can lay it out).
403
565
  reposition();
@@ -408,16 +570,25 @@ export function mountOrb(controller, options = {}) {
408
570
  bubble.style.borderColor = t.cardBorderColor;
409
571
  if (modalTheme?.borderRadius != null)
410
572
  bubble.style.borderRadius = `${modalTheme.borderRadius}px`;
411
- userLine.style.color = t.bodyColor;
412
- replyLine.style.color = t.titleColor;
413
573
  closeBtn.style.color = t.bodyColor;
414
- textInput.style.background = '#fff';
415
- textInput.style.color = '#0f172a';
574
+ exchangeList.querySelectorAll('.fyodos-bubble-user').forEach((node) => {
575
+ node.style.color = t.bodyColor;
576
+ });
577
+ exchangeList.querySelectorAll('.fyodos-bubble-reply').forEach((node) => {
578
+ node.style.color = t.titleColor;
579
+ });
580
+ const inputBg = modalTheme?.inputBackgroundColor ?? '#ffffff';
581
+ textInput.style.background = inputBg;
582
+ textInput.style.color = fiodosInputInkColor(inputBg);
583
+ textInput.style.setProperty('--fyodos-input-placeholder', fiodosInputMutedInkColor(inputBg));
416
584
  sendBtn.style.background = accent;
417
585
  sendBtn.style.borderRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
586
+ syncMicButton();
418
587
  busyRow.querySelectorAll('span').forEach((s) => {
419
588
  s.style.background = accent;
420
589
  });
590
+ // Live-activity line: white (reply color), italic — not the faint gray.
591
+ busyStep.style.color = t.titleColor;
421
592
  }
422
593
  function setBubble(open) {
423
594
  bubbleOpen = open;
@@ -428,6 +599,10 @@ export function mountOrb(controller, options = {}) {
428
599
  textInput.focus();
429
600
  }
430
601
  else {
602
+ bubbleDictation.stop();
603
+ syncMicButton();
604
+ // Collapse the paged history back to the initial page for the next open.
605
+ controller.resetBubbleWindow();
431
606
  // Closing the panel returns the orb to idle (also clears mic fallback).
432
607
  micFallbackActive = false;
433
608
  }
@@ -441,15 +616,27 @@ export function mountOrb(controller, options = {}) {
441
616
  const value = textInput.value.trim();
442
617
  if (!value)
443
618
  return;
619
+ bubbleDictation.stop();
620
+ syncMicButton();
444
621
  haptics.trigger('send');
445
622
  textInput.value = '';
446
623
  // Silent turn: the reply is SHOWN in the bubble (not spoken), and the bubble
447
624
  // STAYS OPEN. This is the unified, reference-matching behaviour.
448
625
  void controller.submitTextTurn(value);
449
626
  }
450
- function openTextPanel() {
451
- controller.cancelAll();
627
+ function openTextPanel(seed, autoSend = false) {
628
+ // Opening the keyboard cancels any in-flight turn; a cancelled message
629
+ // becomes the editable input content unless an explicit seed was given.
630
+ const cancelled = controller.cancelAll();
631
+ const explicitSeed = seed?.trim() ?? '';
632
+ const draft = explicitSeed || cancelled || '';
633
+ textInput.value = draft;
452
634
  setBubble(true);
635
+ // Only an explicit seed may auto-send; a restored cancelled message must
636
+ // wait for the user to edit/resend it.
637
+ if (autoSend && explicitSeed) {
638
+ queueMicrotask(() => submitText());
639
+ }
453
640
  }
454
641
  // ── Keyboard chip (rebuilt when theme changes / visibility toggles) ──────────
455
642
  // Appears beside the ACTIVE orb — whether listening for real (voice granted)
@@ -487,26 +674,62 @@ export function mountOrb(controller, options = {}) {
487
674
  function resolveCurrentBubblePrompt() {
488
675
  return resolveBubblePrompt(matchRouteIntent(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
489
676
  }
677
+ // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
678
+ // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
679
+ // shrinking the font or truncating — the bubble keeps a constant height.
680
+ function layoutThoughtContent(prompt) {
681
+ const t = bubbleTheme;
682
+ const { padH } = resolveBubblePadding(t);
683
+ // Measure the natural (single-copy) text width at the base font size.
684
+ thoughtBubbleEl.style.width = '';
685
+ thoughtBubbleEl.textContent = prompt;
686
+ const measured = thoughtBubbleEl.scrollWidth;
687
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : 280;
688
+ const maxW = Math.min(280, viewport * 0.7);
689
+ const overflow = measured > maxW - t.borderWidth * 2;
690
+ if (!overflow) {
691
+ thoughtBubbleEl.style.width = '';
692
+ return;
693
+ }
694
+ const textWidth = Math.max(0, measured - padH * 2);
695
+ const durationSec = Math.max(4, (textWidth + 48) / 30);
696
+ thoughtBubbleEl.style.width = `${maxW}px`;
697
+ thoughtBubbleEl.textContent = '';
698
+ const track = document.createElement('div');
699
+ track.className = 'fyodos-thought-track';
700
+ track.style.animation = `fyodos-marquee ${durationSec}s linear infinite`;
701
+ const first = document.createElement('span');
702
+ first.textContent = prompt;
703
+ const second = document.createElement('span');
704
+ second.textContent = prompt;
705
+ second.setAttribute('aria-hidden', 'true');
706
+ track.appendChild(first);
707
+ track.appendChild(second);
708
+ thoughtBubbleEl.appendChild(track);
709
+ }
490
710
  function applyThoughtBubbleTheme() {
491
711
  const t = bubbleTheme;
492
- const { fontPx, padV, padH, approxHeight } = resolveBubblePadding(t);
493
- const shapeStyle = resolveBubbleShapeStyle(t, approxHeight);
712
+ const { fontPx, padH, approxHeight } = resolveBubblePadding(t);
713
+ const shapeCss = bubbleShapeToCss(t, approxHeight);
494
714
  thoughtBubbleEl.style.fontSize = `${fontPx}px`;
495
- thoughtBubbleEl.style.padding = `${padV}px ${padH}px`;
715
+ thoughtBubbleEl.style.height = `${approxHeight}px`;
716
+ thoughtBubbleEl.style.padding = `0 ${padH}px`;
496
717
  thoughtBubbleEl.style.color = t.textColor;
497
718
  thoughtBubbleEl.style.background = t.backgroundColor;
498
719
  thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
499
- thoughtBubbleEl.style.borderRadius =
500
- shapeStyle.borderRadius != null ? `${shapeStyle.borderRadius}px` : '';
501
- thoughtBubbleEl.style.borderTopLeftRadius =
502
- shapeStyle.borderTopLeftRadius != null ? `${shapeStyle.borderTopLeftRadius}px` : '';
503
- thoughtBubbleEl.style.borderTopRightRadius =
504
- shapeStyle.borderTopRightRadius != null ? `${shapeStyle.borderTopRightRadius}px` : '';
505
- thoughtBubbleEl.style.borderBottomLeftRadius =
506
- shapeStyle.borderBottomLeftRadius != null ? `${shapeStyle.borderBottomLeftRadius}px` : '';
507
- thoughtBubbleEl.style.borderBottomRightRadius =
508
- shapeStyle.borderBottomRightRadius != null ? `${shapeStyle.borderBottomRightRadius}px` : '';
509
- thoughtBubbleEl.style.clipPath = shapeStyle.clipPath ?? '';
720
+ const setImportant = (prop, value) => {
721
+ if (!value) {
722
+ thoughtBubbleEl.style.removeProperty(prop);
723
+ return;
724
+ }
725
+ thoughtBubbleEl.style.setProperty(prop, value, 'important');
726
+ };
727
+ setImportant('border-radius', shapeCss.borderRadius);
728
+ setImportant('border-top-left-radius', shapeCss.borderTopLeftRadius);
729
+ setImportant('border-top-right-radius', shapeCss.borderTopRightRadius);
730
+ setImportant('border-bottom-left-radius', shapeCss.borderBottomLeftRadius);
731
+ setImportant('border-bottom-right-radius', shapeCss.borderBottomRightRadius);
732
+ setImportant('clip-path', shapeCss.clipPath);
510
733
  }
511
734
  function positionThoughtBubble() {
512
735
  const dep = resolveOrbDeployment(effectivePosition());
@@ -536,11 +759,12 @@ export function mountOrb(controller, options = {}) {
536
759
  thoughtBubbleEl.style.display = 'none';
537
760
  return;
538
761
  }
539
- thoughtBubbleEl.textContent = prompt;
540
- thoughtBubbleEl.setAttribute('aria-label', ui.orbLabel);
762
+ thoughtBubbleEl.setAttribute('aria-label', prompt);
763
+ // Display before measuring so `scrollWidth` (used to detect overflow) is real.
764
+ thoughtBubbleEl.style.display = 'flex';
541
765
  applyThoughtBubbleTheme();
766
+ layoutThoughtContent(prompt);
542
767
  positionThoughtBubble();
543
- thoughtBubbleEl.style.display = 'block';
544
768
  }
545
769
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
546
770
  let overlayEl = null;
@@ -646,7 +870,12 @@ export function mountOrb(controller, options = {}) {
646
870
  // (aborts the request, no reply is delivered) and returns the orb to idle.
647
871
  if (state.showThinking) {
648
872
  haptics.trigger('orbTap');
649
- controller.cancelAll();
873
+ const draft = controller.cancelAll();
874
+ // The cancelled message goes back into the input for editing.
875
+ if (draft && bubbleOpen) {
876
+ textInput.value = draft;
877
+ scrollTextInputToEnd(textInput);
878
+ }
650
879
  return;
651
880
  }
652
881
  haptics.trigger('orbTap');
@@ -680,10 +909,17 @@ export function mountOrb(controller, options = {}) {
680
909
  };
681
910
  sendBtn.onclick = () => {
682
911
  // Send (arrow) while idle; Stop (square) while a turn is processing — tapping
683
- // it cancels the in-flight turn so the user can type/send another message.
684
- if (controller.getState().isBusy) {
912
+ // it cancels the in-flight turn (or running chain) so the user can type again.
913
+ const st = controller.getState();
914
+ if (st.isBusy || st.chainActive) {
685
915
  haptics.trigger('orbTap');
686
- controller.cancelAll();
916
+ // STOP: the cancelled message leaves the panel and goes back into the
917
+ // input so the user can edit and resend it.
918
+ const draft = controller.cancelAll();
919
+ if (draft) {
920
+ textInput.value = draft;
921
+ scrollTextInputToEnd(textInput);
922
+ }
687
923
  textInput.focus();
688
924
  return;
689
925
  }
@@ -695,6 +931,7 @@ export function mountOrb(controller, options = {}) {
695
931
  if (e.key === 'Escape')
696
932
  setBubble(false);
697
933
  };
934
+ textInput.addEventListener('input', () => scrollTextInputToEnd(textInput));
698
935
  if (!enableTextInput) {
699
936
  if (chipEl)
700
937
  chipEl.style.display = 'none';
@@ -737,20 +974,28 @@ export function mountOrb(controller, options = {}) {
737
974
  ? 'speaking'
738
975
  : 'idle';
739
976
  orbVisual.applyState(visual, state.volumeLevel);
740
- // Text bubble: show the current exchange + busy state.
977
+ // Busy through the WHOLE task: single turns and every autonomous chain
978
+ // step (so the real narration never flickers between steps).
979
+ const rowBusy = state.showThinking || state.chainActive;
980
+ // REAL activity line: updates when the agent actually does something new,
981
+ // and clears when the reply lands.
982
+ syncActivityStep(rowBusy, state);
983
+ // Text bubble: show recent exchanges + busy state.
741
984
  if (bubbleOpen) {
742
- const ex = state.lastExchange;
985
+ loaderRow.style.display = state.loadingOlderExchanges ? 'flex' : 'none';
986
+ renderBubbleExchanges(state.recentExchanges);
743
987
  exchangeScroll.style.display = 'flex';
744
- userLine.textContent = ex?.userText ?? '';
745
- replyLine.textContent = ex?.replyText ?? '';
746
- replyLine.style.display = ex?.replyText ? 'block' : 'none';
747
- busyRow.style.display = state.showThinking ? 'flex' : 'none';
988
+ busyRow.style.display = rowBusy ? 'flex' : 'none';
748
989
  // Send (arrow) ↔ Stop (square): while busy the action becomes "cancel".
749
- const busy = state.isBusy;
990
+ // A running autonomous chain counts as busy so the user can interrupt it.
991
+ const busy = state.isBusy || state.chainActive;
750
992
  sendBtn.textContent = busy ? '◼' : '↑';
751
993
  sendBtn.setAttribute('aria-label', busy ? ui.cancelLabel : ui.sendLabel);
752
994
  sendBtn.style.opacity = '1';
753
995
  sendBtn.disabled = false;
996
+ micBtn.style.opacity = busy ? '0.45' : '1';
997
+ micBtn.disabled = busy;
998
+ syncMicButton();
754
999
  }
755
1000
  syncChip(state);
756
1001
  syncThoughtBubble(state);
@@ -798,7 +1043,11 @@ export function mountOrb(controller, options = {}) {
798
1043
  // Dashboard master switch: hide the whole orb (button + chip + bubble live
799
1044
  // inside `root`) when explicitly turned off, show it otherwise. No host
800
1045
  // code change — it flips on the next poll and reappears when re-enabled.
801
- root.style.display = config && config.enabled === false ? 'none' : '';
1046
+ // Internal-profile orbs additionally hide on public (non-development)
1047
+ // hosts: they are for the developer/company, never for end users.
1048
+ const hiddenByProfile = config != null &&
1049
+ shouldHideInternalOrb(config.internalProfile, options.allowInternalOrb === true);
1050
+ root.style.display = (config && config.enabled === false) || hiddenByProfile ? 'none' : '';
802
1051
  if (config) {
803
1052
  publishedAppearance = config.appearance;
804
1053
  chipTheme = config.keyboardChip;
@@ -806,6 +1055,10 @@ export function mountOrb(controller, options = {}) {
806
1055
  bubbleTheme = config.bubbleTheme;
807
1056
  bubbleEnabled = config.bubbleEnabled;
808
1057
  voiceInputEnabled = config.voiceInputEnabled;
1058
+ thinkingStepsEnabled = config.thinkingStepsEnabled;
1059
+ // Idle context retention chosen in the dashboard (0 = never clear) +
1060
+ // expanded memory window when the project's orb profile is 'internal'.
1061
+ controller.setConversationRetention(config.conversationRetentionMinutes, config.internalProfile);
809
1062
  if (usePublishedPosition && config.screenPosition)
810
1063
  screenPosition = config.screenPosition;
811
1064
  }
@@ -823,6 +1076,7 @@ export function mountOrb(controller, options = {}) {
823
1076
  });
824
1077
  return {
825
1078
  unmount() {
1079
+ bubbleDictation.dispose();
826
1080
  unsubscribe();
827
1081
  stopWatch();
828
1082
  window.clearInterval(routeTimer);
@@ -16,6 +16,8 @@ export interface OrbAppearance {
16
16
  glowColor?: string;
17
17
  colorKey?: string;
18
18
  glowIntensity?: number;
19
+ /** 0–100 orb vibration/haptic strength (dashboard advanced option). */
20
+ hapticIntensity?: number;
19
21
  innerBorderColor?: string;
20
22
  innerBorderWidth?: number;
21
23
  size: number;
@@ -27,12 +27,30 @@ export interface ResolvedOrbConfig {
27
27
  * directly on tap and never plays TTS. Default true (absent = voice on).
28
28
  */
29
29
  voiceInputEnabled: boolean;
30
+ /**
31
+ * Dashboard on/off for the agent's live "thinking steps" in the chat panel
32
+ * (`agentThinkingStepsEnabled`). True ONLY when the developer turned it on;
33
+ * then the busy row shows what the agent is doing instead of only the dots.
34
+ * Default false (absent = dots only).
35
+ */
36
+ thinkingStepsEnabled: boolean;
30
37
  /**
31
38
  * Dashboard master visibility switch (`orbEnabled`). False ONLY when the
32
39
  * developer explicitly turned the orb off. The orb is hidden when false and
33
40
  * shown otherwise (including when nothing is published / on error).
34
41
  */
35
42
  enabled: boolean;
43
+ /**
44
+ * Idle context retention in minutes (`conversationRetentionMinutes`).
45
+ * 0 = never clear; null = not published (the SDK's 30-minute default).
46
+ */
47
+ conversationRetentionMinutes: number | null;
48
+ /**
49
+ * True when the project's orb profile is 'internal' (internal/developer
50
+ * software): the agent uses the EXPANDED conversation memory window.
51
+ * Default false ('public' — today's behavior unchanged).
52
+ */
53
+ internalProfile: boolean;
36
54
  }
37
55
  export interface WatchPublishedConfigOptions {
38
56
  /** Poll interval (ms) to pick up dashboard changes in real time. Default 12000. */