handhold-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +427 -0
  2. package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
  3. package/dist/fonts/Phosphor.ttf +0 -0
  4. package/dist/fonts/Phosphor.woff +0 -0
  5. package/dist/fonts/Phosphor.woff2 +0 -0
  6. package/dist/handhold.min.js +1 -0
  7. package/package.json +114 -0
  8. package/src/HandHold.js +1599 -0
  9. package/src/agent/VENDOR.md +203 -0
  10. package/src/agent/agent/AgentLoop.js +366 -0
  11. package/src/agent/agent/prompts.js +163 -0
  12. package/src/agent/agent/tools.js +148 -0
  13. package/src/agent/controller.js +235 -0
  14. package/src/agent/dom/actions.js +456 -0
  15. package/src/agent/dom/domTree.js +1761 -0
  16. package/src/agent/dom/redact.js +98 -0
  17. package/src/agent/dom/serializer.js +332 -0
  18. package/src/agent/dom/utils.js +99 -0
  19. package/src/agent/index.js +169 -0
  20. package/src/agent/llm/connector.js +143 -0
  21. package/src/agent/package.json +3 -0
  22. package/src/agent/policy/PolicyGuard.js +172 -0
  23. package/src/agent/site/manifest.js +145 -0
  24. package/src/agent/ui/ChatWidget.js +219 -0
  25. package/src/agent-mode/AgentMode.js +429 -0
  26. package/src/api/APIClient.js +258 -0
  27. package/src/core/Config.js +207 -0
  28. package/src/core/UiState.js +83 -0
  29. package/src/core/defaults.js +20 -0
  30. package/src/events.js +59 -0
  31. package/src/index.js +77 -0
  32. package/src/intent/ActionVerbMap.js +324 -0
  33. package/src/intent/IntentExtractor.js +317 -0
  34. package/src/observers/ElementScanner.js +284 -0
  35. package/src/observers/NavigationObserver.js +182 -0
  36. package/src/observers/PageObserver.js +293 -0
  37. package/src/session/SessionManager.js +402 -0
  38. package/src/session/SessionStorage.js +148 -0
  39. package/src/ui/HelpWidget.js +1189 -0
  40. package/src/ui/UICoach.js +1725 -0
  41. package/src/ui/components/Button.css +137 -0
  42. package/src/ui/components/Button.js +102 -0
  43. package/src/ui/widget.css +599 -0
  44. package/src/utils/Logger.js +132 -0
  45. package/src/utils/MarkdownRenderer.js +39 -0
  46. package/src/utils/domUtils.js +350 -0
  47. package/src/utils/eventBus.js +102 -0
  48. package/src/utils/index.js +8 -0
  49. package/src/utils/stringUtils.js +202 -0
  50. package/types/index.d.ts +538 -0
@@ -0,0 +1,1189 @@
1
+ /**
2
+ * Hand Hold SDK - Help Widget
3
+ * Main floating help widget with intent menu, search, and guidance display
4
+ */
5
+
6
+ import eventBus from '../utils/eventBus.js';
7
+ import { MarkdownRenderer } from '../utils/MarkdownRenderer.js';
8
+ import '@phosphor-icons/web/regular';
9
+ import './widget.css';
10
+ import { Button, ButtonVariant, ButtonSize } from './components/Button.js';
11
+
12
+ class HelpWidget {
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.container = null;
16
+ this.widget = null;
17
+ this.helpButton = null;
18
+ this.isOpen = false;
19
+ this.currentView = 'menu'; // 'menu', 'loading', 'guidance', 'error', 'clarification'
20
+ this.currentIntents = [];
21
+ this.currentGuidance = null;
22
+ this.currentArticle = null;
23
+ this.currentQuery = '';
24
+ this.errorMessage = null;
25
+ this.clarificationQuestion = null;
26
+ this.clarificationOptions = [];
27
+ this.completionLabel = null;
28
+ // Phase 2.1b: the SDK's single UI-state store, attached by HandHold. View
29
+ // methods mirror their state into it so a host renderer (React) can read
30
+ // one store instead of monkey-patching this widget.
31
+ this.uiState = null;
32
+
33
+ // Inject theme variables
34
+ this._injectThemeVariables();
35
+
36
+ // Bind methods
37
+ this._handleHeaderClick = this._handleHeaderClick.bind(this);
38
+ }
39
+
40
+ /** Attach the SDK UiState store so view changes mirror into it (Phase 2.1b). */
41
+ attachState(uiState) {
42
+ this.uiState = uiState;
43
+ this._syncState();
44
+ }
45
+
46
+ /**
47
+ * Mirror the current view + its data into UiState. Called from _updateBody()
48
+ * (which every view method funnels through) and from show/hide and the couple
49
+ * of view methods that write the DOM directly.
50
+ */
51
+ _syncState(extra = {}) {
52
+ if (!this.uiState) return;
53
+ const view = this.currentView;
54
+ this.uiState.setState({
55
+ view,
56
+ isOpen: this.isOpen,
57
+ intents: this.currentIntents ?? [],
58
+ guidance: this.currentGuidance ?? null,
59
+ article: view === 'article'
60
+ ? { ...(this.currentArticle || {}), query: this.currentQuery }
61
+ : null,
62
+ error: view === 'error' ? (this.errorMessage ?? null) : null,
63
+ clarification: view === 'clarification'
64
+ ? { question: this.clarificationQuestion, options: this.clarificationOptions ?? [] }
65
+ : null,
66
+ completion: view === 'completion' ? (this.completionLabel ?? null) : null,
67
+ ...extra,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Inject CSS variables for theme customization
73
+ */
74
+ _injectThemeVariables() {
75
+ const existingStyles = document.getElementById('handhold-theme-styles');
76
+ if (existingStyles) {
77
+ existingStyles.remove();
78
+ }
79
+
80
+ const { primaryColor, zIndex } = this.config;
81
+
82
+ // Helper to darken color
83
+ const adjustColor = (color, amount) => {
84
+ const hex = color.replace('#', '');
85
+ const num = parseInt(hex, 16);
86
+ let r = (num >> 16) + amount;
87
+ if (r > 255) r = 255; else if (r < 0) r = 0;
88
+ let b = ((num >> 8) & 0x00FF) + amount;
89
+ if (b > 255) b = 255; else if (b < 0) b = 0;
90
+ let g = (num & 0x0000FF) + amount;
91
+ if (g > 255) g = 255; else if (g < 0) g = 0;
92
+ return `#${(g | (b << 8) | (r << 16)).toString(16).padStart(6, '0')}`;
93
+ };
94
+
95
+ const primaryDark = adjustColor(primaryColor || '#004AF5', -20);
96
+
97
+ const styles = document.createElement('style');
98
+ styles.id = 'handhold-theme-styles';
99
+ styles.textContent = `
100
+ #handhold-container {
101
+ --hh-primary: ${primaryColor || '#004AF5'};
102
+ --hh-primary-dark: ${primaryDark};
103
+ --hh-z-index: ${zIndex || 9999};
104
+ }
105
+ `;
106
+
107
+ document.head.appendChild(styles);
108
+ }
109
+
110
+ /**
111
+ * Render the widget
112
+ */
113
+ render() {
114
+ if (this.container) return;
115
+
116
+ // Create container
117
+ this.container = document.createElement('div');
118
+ this.container.id = 'handhold-container';
119
+
120
+ // Apply theme class
121
+ if (this.config.theme === 'dark') {
122
+ this.container.classList.add('dark');
123
+ }
124
+
125
+ // Determine position class
126
+ const pos = this.config.position || 'bottom-right';
127
+ let posClass = 'position-bottom-right';
128
+ if (pos.includes('top')) {
129
+ if (pos.includes('left')) posClass = 'position-top-left';
130
+ else posClass = 'position-top-right';
131
+ } else {
132
+ if (pos.includes('left')) posClass = 'position-bottom-left';
133
+ // default is bottom-right
134
+ }
135
+
136
+ // Create help button
137
+ if (this.config.showHelpButton) {
138
+ this.helpButton = new Button({
139
+ text: this.config.helpButtonText || '?',
140
+ variant: ButtonVariant.FAB,
141
+ className: `handhold-help-button ${posClass}`,
142
+ attributes: {
143
+ 'aria-label': this.config.helpButtonAriaLabel
144
+ },
145
+ onClick: () => this.toggle()
146
+ }).create();
147
+ this.container.appendChild(this.helpButton);
148
+ }
149
+
150
+ // Create widget panel
151
+ this.widget = document.createElement('div');
152
+ this.widget.className = `handhold-widget ${posClass}`;
153
+ this.widget.innerHTML = this._renderWidgetContent();
154
+ this.container.appendChild(this.widget);
155
+
156
+ // Add to document
157
+ document.body.appendChild(this.container);
158
+
159
+ // Setup event listeners
160
+ this._setupEventListeners();
161
+ }
162
+
163
+ /**
164
+ * Render widget content
165
+ */
166
+ _renderWidgetContent() {
167
+ return `
168
+ <div class="handhold-widget-header">
169
+ <h3 class="handhold-widget-title">Get help</h3>
170
+ <div class="handhold-header-actions">
171
+ ${new Button({
172
+ icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
173
+ variant: ButtonVariant.GHOST,
174
+ size: ButtonSize.ICON_LG,
175
+ className: 'handhold-widget-close handhold-widget-exit',
176
+ attributes: { 'aria-label': 'Close help' }
177
+ }).render()}
178
+ </div>
179
+ </div>
180
+ <div class="handhold-widget-body">
181
+ ${this._renderViewContent()}
182
+ </div>
183
+ `;
184
+ }
185
+
186
+ /**
187
+ * Render current view content
188
+ */
189
+ _renderViewContent() {
190
+ switch (this.currentView) {
191
+ case 'loading':
192
+ return this._renderLoading();
193
+ case 'guidance':
194
+ return this._renderGuidance();
195
+ case 'agent':
196
+ return this._renderAgentRun();
197
+ case 'error':
198
+ return this._renderError(this.errorMessage);
199
+ case 'clarification':
200
+ return this._renderClarification();
201
+ case 'article':
202
+ return this._renderArticle();
203
+ case 'menu':
204
+ default:
205
+ return this._renderIntentMenu();
206
+ }
207
+ }
208
+
209
+ _renderArticle() {
210
+ if (!this.currentArticle) return '';
211
+ const title = this._escapeHtml(this.currentArticle.title || 'Explanation');
212
+ const summary = this.currentArticle.summary ? this._escapeHtml(this.currentArticle.summary) : '';
213
+
214
+ // Parse markdown instead of just escaping
215
+ const contentHtml = MarkdownRenderer.parse(this.currentArticle.content || '');
216
+
217
+ const query = this.currentQuery ? this._escapeHtml(this.currentQuery) : '';
218
+ return `
219
+ <div class="handhold-article">
220
+ <div class="handhold-article-query">${query ? `Selected: "${query}"` : ''}</div>
221
+ <h4 class="handhold-article-title">${title}</h4>
222
+ ${summary ? `<div class="handhold-article-summary">${summary}</div>` : ''}
223
+ <div class="handhold-article-content">${contentHtml}</div>
224
+ <div class="handhold-article-actions">
225
+ ${new Button({
226
+ text: 'Back',
227
+ variant: ButtonVariant.SECONDARY,
228
+ attributes: { 'data-action': 'back' }
229
+ }).render()}
230
+ </div>
231
+ </div>
232
+ `;
233
+ }
234
+
235
+ /**
236
+ * Render intent menu view
237
+ */
238
+ _renderIntentMenu() {
239
+ const intentsHtml = this.currentIntents.map((intent, index) => `
240
+ <button class="handhold-intent-item" data-intent-index="${index}" data-intent-key="${intent.key}">
241
+ <span class="handhold-intent-label">${intent.label}</span>
242
+ <span class="handhold-btn-text">Start</span>
243
+ </button>
244
+ `).join('');
245
+
246
+ return `
247
+ <div class="handhold-search-container">
248
+ <label class="handhold-search-label">Ask a question</label>
249
+ <div class="handhold-input-wrapper">
250
+ <input
251
+ type="text"
252
+ class="handhold-search-input"
253
+ placeholder="${this.config.widgetPlaceholder}"
254
+ aria-label="Ask a question"
255
+ >
256
+ ${new Button({
257
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"></path><path d="M22 2L15 22L11 13L2 9L22 2Z"></path></svg>',
258
+ variant: ButtonVariant.OUTLINE,
259
+ size: ButtonSize.ICON,
260
+ className: 'handhold-send-btn',
261
+ attributes: { 'aria-label': 'Send' }
262
+ }).render()}
263
+ </div>
264
+ </div>
265
+ <div class="handhold-intent-menu">
266
+ <h4 class="handhold-section-title">Page Actions</h4>
267
+ ${intentsHtml || '<p class="handhold-empty-state">No actions available for this page</p>'}
268
+
269
+ <div class="handhold-divider"></div>
270
+
271
+ ${new Button({
272
+ text: 'Contact Support',
273
+ icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>',
274
+ variant: ButtonVariant.OUTLINE,
275
+ size: ButtonSize.MEDIUM,
276
+ className: 'handhold-intent-item handhold-contact-support'
277
+ }).render()}
278
+ </div>
279
+ `;
280
+ }
281
+
282
+ /**
283
+ * Render loading view
284
+ */
285
+ _renderLoading() {
286
+ return `
287
+ <div class="handhold-skeleton-container">
288
+ <div class="handhold-skeleton title"></div>
289
+ <div class="handhold-skeleton text"></div>
290
+ <div class="handhold-skeleton text"></div>
291
+ <div class="handhold-skeleton text short"></div>
292
+ </div>
293
+ `;
294
+ }
295
+
296
+ /**
297
+ * Render guidance view
298
+ */
299
+ _renderGuidance() {
300
+ if (!this.currentGuidance) return '';
301
+
302
+ const { steps, current_step, summary, total_steps } = this.currentGuidance;
303
+ const progress = Math.round(((current_step) / total_steps) * 100);
304
+
305
+ const stepsHtml = steps.map((step, index) => {
306
+ let statusClass = '';
307
+ if (index < current_step) statusClass = 'completed';
308
+ else if (index === current_step) statusClass = 'active';
309
+
310
+ return `
311
+ <div class="handhold-step ${statusClass}" data-step-index="${index}">
312
+ <div class="handhold-step-card">
313
+ <div class="handhold-step-content">
314
+ <div class="handhold-step-number">${index < current_step ? '' : index + 1}</div>
315
+ <div class="handhold-step-text">${step.text}</div>
316
+ </div>
317
+ <div class="handhold-step-action handhold-step-actions">
318
+ ${index === current_step ? `
319
+ ${new Button({
320
+ text: 'Done',
321
+ variant: ButtonVariant.SUCCESS,
322
+ size: ButtonSize.SMALL,
323
+ attributes: { 'data-action': 'mark-done', 'data-step-index': index }
324
+ }).render()}
325
+ ${new Button({
326
+ text: 'Show UI',
327
+ variant: ButtonVariant.PRIMARY,
328
+ size: ButtonSize.SMALL,
329
+ attributes: { 'data-action': 'show-ui', 'data-show-ui': index, 'data-step-index': index }
330
+ }).render()}
331
+ ` : ''}
332
+ </div>
333
+ </div>
334
+ </div>
335
+ `;
336
+ }).join('');
337
+
338
+ return `
339
+ <div class="handhold-guidance">
340
+ <div class="handhold-progress">
341
+ <div class="handhold-progress-bar">
342
+ <div class="handhold-progress-fill" style="width: ${progress}%"></div>
343
+ </div>
344
+ <span class="handhold-progress-text">${current_step}/${total_steps}</span>
345
+ </div>
346
+ ${summary ? `<div class="handhold-guidance-summary">${summary}</div>` : ''}
347
+ <div class="handhold-steps-list">
348
+ ${stepsHtml}
349
+ </div>
350
+ <div class="handhold-guidance-actions">
351
+ ${this.config.agentMode && this.currentGuidance.agent_plan ? new Button({
352
+ text: 'Do it for me',
353
+ variant: ButtonVariant.PRIMARY,
354
+ attributes: { 'data-action': 'do-it-for-me' }
355
+ }).render() : ''}
356
+ ${new Button({
357
+ text: 'Close',
358
+ variant: ButtonVariant.SECONDARY,
359
+ attributes: { 'data-action': 'close' }
360
+ }).render()}
361
+ </div>
362
+ </div>
363
+ `;
364
+ }
365
+
366
+ /**
367
+ * Render the agent run view (H5): goal line, live activity feed, a slot for
368
+ * Allow/Deny confirm cards, and Stop/Close actions. Agent- and page-derived
369
+ * strings are ALWAYS inserted with textContent (never innerHTML) — model
370
+ * output and page text are untrusted.
371
+ */
372
+ _renderAgentRun() {
373
+ return `
374
+ <div class="handhold-agent">
375
+ <div class="handhold-agent-goal"></div>
376
+ <div class="handhold-agent-feed" aria-live="polite"></div>
377
+ <div class="handhold-agent-confirm-slot"></div>
378
+ <div class="handhold-guidance-actions">
379
+ ${new Button({
380
+ text: 'Stop',
381
+ variant: ButtonVariant.SECONDARY,
382
+ size: ButtonSize.SMALL,
383
+ attributes: { 'data-action': 'agent-stop' }
384
+ }).render()}
385
+ ${new Button({
386
+ text: 'Close',
387
+ variant: ButtonVariant.SECONDARY,
388
+ attributes: { 'data-action': 'close' }
389
+ }).render()}
390
+ </div>
391
+ </div>
392
+ `;
393
+ }
394
+
395
+ /**
396
+ * Render error view
397
+ */
398
+ _renderError(message) {
399
+ return `
400
+ <div class="handhold-error" style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; height: 100%;">
401
+ <div style="width: 112px; height: 112px; margin-bottom: 8px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;">
402
+ <img src="/illustration-error.svg" alt="Error" style="width: 100%; height: 100%; object-fit: contain;" />
403
+ </div>
404
+ <p class="handhold-error-message" style="font-size: 18px; font-weight: 700; color: #0F172A; margin-bottom: 4px; line-height: 1.5;">We're sorry, something went wrong</p>
405
+ <p style="font-size: 14px; color: #64748B; margin-bottom: 16px; line-height: 1.5; padding: 0 8px;">${message || 'We encountered an issue while trying to pull the definitions of the selected terms please try again.'}</p>
406
+ ${new Button({
407
+ text: 'Try again',
408
+ variant: ButtonVariant.PRIMARY,
409
+ className: 'handhold-btn-primary',
410
+ attributes: { 'data-action': 'retry', 'style': 'width: 100%; justify-content: center;' }
411
+ }).render()}
412
+ </div>
413
+ `;
414
+ }
415
+
416
+ /**
417
+ * Render clarification view
418
+ */
419
+ _renderClarification() {
420
+ const optionsHtml = (this.clarificationOptions || []).map(opt => `
421
+ <button class="handhold-intent-item" data-intent-key="${opt.key}">
422
+ ${opt.label}
423
+ </button>
424
+ `).join('');
425
+
426
+ return `
427
+ <div class="handhold-clarification">
428
+ <p class="handhold-clarification-question">${this.clarificationQuestion || 'Did you mean one of these?'}</p>
429
+ <div class="handhold-clarification-options">
430
+ ${optionsHtml}
431
+ </div>
432
+ </div>
433
+ `;
434
+ }
435
+
436
+ /**
437
+ * Handle "Show UI" click for a step
438
+ * @private
439
+ */
440
+ _onShowUIClick(stepId, selector) {
441
+ if (!selector) return;
442
+
443
+ // Check if element exists before starting animation
444
+ const element = document.querySelector(selector);
445
+ if (!element) {
446
+ // Fallback to highlight request which handles "not found" state
447
+ eventBus.emit('ui:highlight_request', { selector, stepId });
448
+ return;
449
+ }
450
+
451
+ // Add scanner animation
452
+ const btn = this.widget.querySelector(`[data-show-ui="${stepId}"]`);
453
+ if (btn) {
454
+ // Prevent multiple clicks
455
+ if (btn.classList.contains('loading')) return;
456
+
457
+ btn.classList.add('loading');
458
+ const originalText = btn.textContent;
459
+ btn.textContent = 'Locating...';
460
+
461
+ // Trigger scanning animation via UICoach
462
+ eventBus.emit('ui:scan_request', { selector });
463
+
464
+ // Simulate scanning delay for effect
465
+ setTimeout(() => {
466
+ eventBus.emit('ui:highlight_request', {
467
+ selector,
468
+ stepId
469
+ });
470
+
471
+ // Reset button state
472
+ btn.classList.remove('loading');
473
+ btn.textContent = originalText;
474
+ }, 1000); // Slightly longer for better effect
475
+ } else {
476
+ // Fallback if button not found (rare)
477
+ eventBus.emit('ui:highlight_request', { selector, stepId });
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Setup event listeners
483
+ */
484
+ _setupEventListeners() {
485
+ // Header actions
486
+ const header = this.widget.querySelector('.handhold-widget-header');
487
+ if (header) {
488
+ header.addEventListener('click', this._handleHeaderClick);
489
+ }
490
+
491
+ // Delegate event handling for dynamic content
492
+ this.widget.addEventListener('click', (e) => {
493
+ // Prevent click from bubbling to document (prevents immediate close)
494
+ e.stopPropagation();
495
+
496
+ // Send button click
497
+ const sendBtn = e.target.closest('.handhold-send-btn');
498
+ if (sendBtn) {
499
+ const input = this.widget.querySelector('.handhold-search-input');
500
+ if (input) {
501
+ const query = input.value.trim();
502
+ if (query) {
503
+ eventBus.emit('query:submitted', query);
504
+ }
505
+ }
506
+ }
507
+
508
+ // Intent item click
509
+ const intentItem = e.target.closest('.handhold-intent-item');
510
+ if (intentItem) {
511
+ const key = intentItem.dataset.intentKey;
512
+ const index = parseInt(intentItem.dataset.intentIndex, 10);
513
+ const intent = this.currentIntents[index] || { key };
514
+ eventBus.emit('intent:selected', intent);
515
+ }
516
+
517
+ // Resolve the action element — handles clicks on inner spans inside Button components
518
+ const actionEl = e.target.closest('[data-action]');
519
+ const action = actionEl?.dataset?.action;
520
+
521
+ // Retry button
522
+ if (action === 'retry') {
523
+ eventBus.emit('help:open');
524
+ }
525
+
526
+ if (action === 'back') {
527
+ this.showIntentMenu(this.currentIntents);
528
+ }
529
+
530
+ // Show UI button
531
+ if (action === 'show-ui') {
532
+ const stepIndex = parseInt(actionEl.dataset.stepIndex, 10);
533
+ // Find selector from current guidance steps
534
+ const step = this.currentGuidance?.steps?.[stepIndex];
535
+ const selector = step?.selector;
536
+
537
+ if (selector) {
538
+ // Call internal handler for animation
539
+ this._onShowUIClick(stepIndex, selector);
540
+ } else {
541
+ // Fallback just in case
542
+ eventBus.emit('step:show_ui', { stepIndex });
543
+ }
544
+ }
545
+
546
+ // Mark Done button
547
+ if (action === 'mark-done') {
548
+ eventBus.emit('step:done');
549
+ }
550
+
551
+ // "Do it for me" (H5): switch to the agent view, then ask AgentMode to run
552
+ if (action === 'do-it-for-me') {
553
+ const plan = this.currentGuidance?.agent_plan;
554
+ if (plan) {
555
+ this.showAgentRun(plan);
556
+ eventBus.emit('agent:start', plan);
557
+ }
558
+ }
559
+
560
+ // Stop a running agent (cooperative)
561
+ if (action === 'agent-stop') {
562
+ eventBus.emit('agent:stop');
563
+ }
564
+
565
+ // Close button (bottom)
566
+ if (action === 'close') {
567
+ this.hide();
568
+ }
569
+
570
+ });
571
+
572
+ // Search input
573
+ this.widget.addEventListener('keydown', (e) => {
574
+ if (e.target.classList.contains('handhold-search-input') && e.key === 'Enter') {
575
+ const query = e.target.value.trim();
576
+ if (query) {
577
+ eventBus.emit('query:submitted', query);
578
+ }
579
+ }
580
+ });
581
+
582
+ // Click outside to close
583
+ document.addEventListener('click', (e) => {
584
+ // Ignore if element was removed from DOM (e.g. by view change)
585
+ if (!e.target.isConnected) return;
586
+
587
+ // Check if click is inside any UICoach element (e.g. tooltip, highlight)
588
+ const isUICoachElement = e.target.closest('.handhold-step-tooltip') ||
589
+ e.target.closest('.handhold-highlight') ||
590
+ e.target.closest('.handhold-arrow');
591
+
592
+ // If in guidance mode, do NOT close on outside click (user needs to interact with app)
593
+ // Also don't close if interacting with UICoach elements
594
+ if (this.currentView === 'guidance' || isUICoachElement) return;
595
+
596
+ if (this.isOpen && !this.container.contains(e.target)) {
597
+ this.hide();
598
+ }
599
+ });
600
+
601
+ // Escape key to close
602
+ document.addEventListener('keydown', (e) => {
603
+ if (this.isOpen && e.key === 'Escape') {
604
+ this.hide();
605
+ }
606
+ });
607
+ }
608
+
609
+
610
+
611
+ /**
612
+ * Handle header clicks (Minimize/Exit)
613
+ */
614
+ _handleHeaderClick(e) {
615
+ e.stopPropagation();
616
+
617
+ // Exit button — actually ends the workflow (hide() only minimizes now).
618
+ if (e.target.closest('.handhold-widget-exit')) {
619
+ eventBus.emit('session:close');
620
+ return;
621
+ }
622
+
623
+ // Minimize button
624
+ if (e.target.closest('.handhold-widget-minimize')) {
625
+ this.collapse();
626
+ return;
627
+ }
628
+
629
+ // Click on header itself (toggle collapse)
630
+ // Only if collapsed, expand it. If expanded, do nothing (unless we want double-click to collapse?)
631
+ if (this.isCollapsed) {
632
+ this.expand();
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Collapse the widget
638
+ */
639
+ collapse() {
640
+ this.isCollapsed = true;
641
+ this.widget.classList.add('collapsed');
642
+ // Change minimize icon to expand
643
+ const minBtn = this.widget.querySelector('.handhold-widget-minimize');
644
+ if (minBtn) minBtn.innerHTML = `
645
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
646
+ <polyline points="18 15 12 9 6 15"></polyline>
647
+ </svg>
648
+ `;
649
+ }
650
+
651
+ /**
652
+ * Expand the widget
653
+ */
654
+ expand() {
655
+ this.isCollapsed = false;
656
+ this.widget.classList.remove('collapsed');
657
+ // Restore minimize icon
658
+ const minBtn = this.widget.querySelector('.handhold-widget-minimize');
659
+ if (minBtn) minBtn.innerHTML = `
660
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
661
+ <line x1="5" y1="12" x2="19" y2="12"></line>
662
+ </svg>
663
+ `;
664
+ }
665
+
666
+ /**
667
+ * Update widget body content
668
+ */
669
+ _updateBody() {
670
+ const body = this.widget.querySelector('.handhold-widget-body');
671
+ if (body) {
672
+ body.innerHTML = this._renderViewContent();
673
+ }
674
+ this._syncState();
675
+ }
676
+
677
+ _escapeHtml(value) {
678
+ return String(value)
679
+ .replace(/&/g, '&amp;')
680
+ .replace(/</g, '&lt;')
681
+ .replace(/>/g, '&gt;')
682
+ .replace(/"/g, '&quot;')
683
+ .replace(/'/g, '&#39;');
684
+ }
685
+
686
+ // ============================================
687
+ // Public Methods
688
+ // ============================================
689
+
690
+ /**
691
+ * Toggle widget visibility
692
+ */
693
+ toggle() {
694
+ if (this.isOpen) {
695
+ this.hide();
696
+ } else {
697
+ this.show();
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Show the widget
703
+ */
704
+ show() {
705
+ if (this.isOpen) return;
706
+
707
+ this.isOpen = true;
708
+ this.widget.classList.add('open');
709
+
710
+ if (this.helpButton) {
711
+ this.helpButton.classList.add('hidden');
712
+ }
713
+
714
+ this._syncState();
715
+ eventBus.emit('help:open');
716
+ }
717
+
718
+ /**
719
+ * Hide the widget
720
+ */
721
+ hide() {
722
+ if (!this.isOpen) return;
723
+
724
+ this.isOpen = false;
725
+ this.widget.classList.remove('open');
726
+
727
+ if (this.helpButton) {
728
+ this.helpButton.classList.remove('hidden');
729
+ }
730
+
731
+ // hide() is now purely visual (a "minimize"): it does NOT end the workflow.
732
+ // Closing the panel keeps the session alive so reopening resumes it. The
733
+ // session is ended only by an explicit exit (the widget's exit button /
734
+ // HandHold.exitWorkflow()), so a stray outside-click/Escape no longer
735
+ // discards a workflow the user is midway through.
736
+ this._syncState();
737
+ }
738
+
739
+ /**
740
+ * Reset the panel back to a clean menu state and clear any workflow data so
741
+ * stale guidance can't be re-rendered or resumed after the session ends.
742
+ * Called by the SDK when a workflow is exited/closed for good.
743
+ */
744
+ resetView() {
745
+ this.currentView = 'menu';
746
+ this.currentGuidance = null;
747
+ this.errorMessage = null;
748
+ this.clarificationQuestion = null;
749
+ this.clarificationOptions = null;
750
+ this.completionLabel = null;
751
+ this._syncState();
752
+ }
753
+
754
+ /**
755
+ * Show intent menu with intents
756
+ * @param {Array} intents - Array of intent objects
757
+ */
758
+ showIntentMenu(intents) {
759
+ this.currentView = 'menu';
760
+ this.currentIntents = intents || [];
761
+ this._updateBody();
762
+ }
763
+
764
+ /**
765
+ * Show loading state
766
+ */
767
+ showLoading() {
768
+ this.currentView = 'loading';
769
+ this._updateBody();
770
+ }
771
+
772
+ /**
773
+ * Show guidance steps
774
+ * @param {Object} guidance - Guidance object with steps
775
+ */
776
+ showGuidanceTooltip(guidance) {
777
+ this.currentView = 'guidance';
778
+ this.currentGuidance = guidance;
779
+ this._updateBody();
780
+ }
781
+
782
+ // ============================================
783
+ // Agent mode UI (H5)
784
+ // ============================================
785
+ // Everything below renders agent-/page-derived strings via textContent —
786
+ // model output and page text must never become HTML.
787
+
788
+ /**
789
+ * Switch to the agent run view for a plan.
790
+ * @param {Object} plan - agent_plan ({goal, hints, target_page_path})
791
+ */
792
+ showAgentRun(plan) {
793
+ this.currentView = 'agent';
794
+ this.currentAgentPlan = plan || null;
795
+ this._updateBody();
796
+ const goalEl = this.widget.querySelector('.handhold-agent-goal');
797
+ if (goalEl) goalEl.textContent = plan?.goal || '';
798
+ }
799
+
800
+ /** The activity feed element, creating the agent view if needed. */
801
+ _agentFeed() {
802
+ let feed = this.widget.querySelector('.handhold-agent-feed');
803
+ if (!feed) {
804
+ this.showAgentRun(this.currentAgentPlan);
805
+ feed = this.widget.querySelector('.handhold-agent-feed');
806
+ }
807
+ return feed;
808
+ }
809
+
810
+ /** One human-readable line per engine activity event. */
811
+ _agentActivityText(ev) {
812
+ switch (ev?.type) {
813
+ case 'run_start':
814
+ return `Starting: ${ev.goal || ''}`;
815
+ case 'thinking':
816
+ return `Thinking… (step ${ev.step})`;
817
+ case 'executing':
818
+ return `▶ ${ev.nextGoal || ev.actionName || 'Working…'}`;
819
+ case 'executed':
820
+ return ev.resultMessage || '';
821
+ case 'done':
822
+ return ev.success === false ? '⚠ Task ended' : '✓ Task complete';
823
+ case 'resuming':
824
+ return `Resuming: ${ev.task || ''}`;
825
+ case 'checkpoint_discarded':
826
+ return `Agent checkpoint discarded: ${ev.reason || ''}`;
827
+ case 'info':
828
+ return ev.message || '';
829
+ default:
830
+ return ev?.message || ev?.resultMessage || '';
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Append an engine activity line to the feed (textContent only).
836
+ * @param {Object} ev - activity event from AgentMode
837
+ */
838
+ addAgentActivity(ev) {
839
+ if (!this.widget) return;
840
+ const feed = this._agentFeed();
841
+ if (!feed) return;
842
+ const text = this._agentActivityText(ev);
843
+ if (!text) return;
844
+ const line = document.createElement('div');
845
+ line.className = `handhold-agent-line handhold-agent-${ev?.type || 'event'}`;
846
+ line.textContent = text;
847
+ feed.appendChild(line);
848
+ feed.scrollTop = feed.scrollHeight;
849
+ }
850
+
851
+ /**
852
+ * Render a fail-closed Allow/Deny card for a sensitive action. `respond` is
853
+ * AgentMode's resolver: only an explicit Allow click passes true; the card
854
+ * is one-shot (buttons removed after the first decision).
855
+ * @param {Object} pending - {name, input, reason} from PolicyGuard
856
+ * @param {(allowed:boolean)=>void} respond
857
+ */
858
+ showAgentConfirm(pending, respond) {
859
+ if (!this.widget) return;
860
+ this._agentFeed(); // ensure the agent view exists
861
+ const slot = this.widget.querySelector('.handhold-agent-confirm-slot');
862
+ if (!slot) return;
863
+
864
+ const card = document.createElement('div');
865
+ card.className = 'handhold-agent-confirm';
866
+
867
+ const title = document.createElement('div');
868
+ title.className = 'handhold-agent-confirm-title';
869
+ title.textContent = 'Approval needed';
870
+
871
+ const reason = document.createElement('div');
872
+ reason.className = 'handhold-agent-confirm-reason';
873
+ reason.textContent = pending?.reason || pending?.name || 'Sensitive action';
874
+
875
+ const buttons = document.createElement('div');
876
+ buttons.className = 'handhold-agent-confirm-buttons';
877
+
878
+ let decided = false;
879
+ const decide = (allowed) => {
880
+ if (decided) return;
881
+ decided = true;
882
+ buttons.remove(); // one-shot: no double decisions
883
+ const note = document.createElement('div');
884
+ note.className = 'handhold-agent-confirm-decision';
885
+ note.textContent = allowed ? 'Allowed' : 'Denied';
886
+ card.appendChild(note);
887
+ try {
888
+ respond(allowed === true);
889
+ } catch (e) {
890
+ /* respond must never throw into the UI */
891
+ }
892
+ };
893
+
894
+ const allowBtn = document.createElement('button');
895
+ allowBtn.type = 'button';
896
+ allowBtn.dataset.agentConfirm = 'allow';
897
+ allowBtn.className = 'handhold-btn handhold-btn-primary handhold-btn-small';
898
+ allowBtn.textContent = 'Allow';
899
+ allowBtn.addEventListener('click', () => decide(true));
900
+
901
+ const denyBtn = document.createElement('button');
902
+ denyBtn.type = 'button';
903
+ denyBtn.dataset.agentConfirm = 'deny';
904
+ denyBtn.className = 'handhold-btn handhold-btn-secondary handhold-btn-small';
905
+ denyBtn.textContent = 'Deny';
906
+ denyBtn.addEventListener('click', () => decide(false));
907
+
908
+ buttons.appendChild(allowBtn);
909
+ buttons.appendChild(denyBtn);
910
+ card.appendChild(title);
911
+ card.appendChild(reason);
912
+ card.appendChild(buttons);
913
+ slot.appendChild(card);
914
+ }
915
+
916
+ /**
917
+ * Render an inline question card for the engine's ask_user channel.
918
+ * @param {string} question
919
+ * @param {(answer:string)=>void} respond
920
+ */
921
+ showAgentAsk(question, respond) {
922
+ if (!this.widget) return;
923
+ this._agentFeed();
924
+ const slot = this.widget.querySelector('.handhold-agent-confirm-slot');
925
+ if (!slot) return;
926
+
927
+ const card = document.createElement('div');
928
+ card.className = 'handhold-agent-ask';
929
+
930
+ const label = document.createElement('div');
931
+ label.className = 'handhold-agent-ask-question';
932
+ label.textContent = question || 'The agent has a question.';
933
+
934
+ const input = document.createElement('input');
935
+ input.type = 'text';
936
+ input.className = 'handhold-agent-ask-input';
937
+
938
+ let sent = false;
939
+ const send = () => {
940
+ if (sent) return;
941
+ sent = true;
942
+ const answer = input.value;
943
+ card.remove();
944
+ try {
945
+ respond(answer);
946
+ } catch (e) {
947
+ /* respond must never throw into the UI */
948
+ }
949
+ };
950
+
951
+ const sendBtn = document.createElement('button');
952
+ sendBtn.type = 'button';
953
+ sendBtn.className = 'handhold-btn handhold-btn-primary handhold-btn-small';
954
+ sendBtn.textContent = 'Send';
955
+ sendBtn.addEventListener('click', send);
956
+ input.addEventListener('keydown', (e) => {
957
+ if (e.key === 'Enter') send();
958
+ });
959
+
960
+ card.appendChild(label);
961
+ card.appendChild(input);
962
+ card.appendChild(sendBtn);
963
+ slot.appendChild(card);
964
+ input.focus();
965
+ }
966
+
967
+ /**
968
+ * Final run report line.
969
+ * @param {Object} result - {success, text, steps}
970
+ */
971
+ showAgentDone(result) {
972
+ if (!this.widget || !result) return;
973
+ const feed = this._agentFeed();
974
+ if (!feed) return;
975
+ const line = document.createElement('div');
976
+ line.className = `handhold-agent-line handhold-agent-report ${result.success ? 'success' : 'failure'}`;
977
+ line.textContent = `${result.success ? '✓' : '⚠'} ${result.text || ''}`;
978
+ feed.appendChild(line);
979
+ feed.scrollTop = feed.scrollHeight;
980
+ }
981
+
982
+ /**
983
+ * Update guidance (step progress)
984
+ * @param {Object} session - Updated session
985
+ */
986
+ updateTooltip(session) {
987
+ if (this.currentGuidance) {
988
+ this.currentGuidance.current_step = session.current_step;
989
+ this._updateBody();
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Show error message
995
+ * @param {string} message - Error message
996
+ */
997
+ showError(message) {
998
+ this.currentView = 'error';
999
+ this.errorMessage = message;
1000
+ this._updateBody();
1001
+ }
1002
+
1003
+ /**
1004
+ * Show clarification question
1005
+ * @param {string} question - Clarification question
1006
+ * @param {Array} options - Intent options
1007
+ */
1008
+ showClarification(question, options) {
1009
+ this.currentView = 'clarification';
1010
+ this.clarificationQuestion = question;
1011
+ this.clarificationOptions = options;
1012
+ this._updateBody();
1013
+ }
1014
+
1015
+ showArticle(article, query) {
1016
+ this.currentView = 'article';
1017
+ this.currentArticle = article;
1018
+ this.currentQuery = query || '';
1019
+ this._updateBody();
1020
+ }
1021
+
1022
+ /**
1023
+ * Show text-only guidance (when element not found)
1024
+ * @param {Object} session - Full session object or steps info
1025
+ * @param {string} fallbackMessage - Additional message
1026
+ */
1027
+ showTextOnlyGuidance(session, fallbackMessage) {
1028
+ // If session is just a string (legacy call), wrap it
1029
+ if (typeof session === 'string') {
1030
+ const text = session;
1031
+ this.showGuidanceTooltip({
1032
+ steps: [{ text, order: 1 }],
1033
+ current_step: 0,
1034
+ total_steps: 1,
1035
+ summary: `<div class="handhold-alert handhold-alert-warning"><span class="handhold-alert-title">Element not found</span>${fallbackMessage}</div>`,
1036
+ });
1037
+ return;
1038
+ }
1039
+
1040
+ this.showGuidanceTooltip({
1041
+ steps: session.steps,
1042
+ current_step: session.current_step,
1043
+ total_steps: session.steps.length,
1044
+ summary: `<div class="handhold-alert handhold-alert-warning"><span class="handhold-alert-title">Element not found</span>${fallbackMessage}</div>`,
1045
+ });
1046
+ }
1047
+
1048
+ /**
1049
+ * Show completion screen when all guidance steps are done
1050
+ * @param {string} intentLabel - The intent the user just completed
1051
+ */
1052
+ showCompletion(intentLabel) {
1053
+ this.currentView = 'completion';
1054
+ this.completionLabel = intentLabel;
1055
+ this._syncState();
1056
+ const body = this.widget.querySelector('.handhold-widget-body');
1057
+ if (!body) return;
1058
+
1059
+ body.innerHTML = `
1060
+ <div class="handhold-completion">
1061
+ <div class="handhold-completion-icon">
1062
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
1063
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
1064
+ <polyline points="22 4 12 14.01 9 11.01"></polyline>
1065
+ </svg>
1066
+ </div>
1067
+ <h3 class="handhold-completion-title">You're all done!</h3>
1068
+ <p class="handhold-completion-message">
1069
+ ${intentLabel ? `You've successfully completed <strong>${intentLabel}</strong>.` : "You've completed all the steps."}
1070
+ </p>
1071
+ ${new Button({
1072
+ text: 'Got it',
1073
+ variant: ButtonVariant.PRIMARY,
1074
+ size: ButtonSize.MEDIUM,
1075
+ attributes: { 'data-action': 'close' }
1076
+ }).render()}
1077
+ </div>
1078
+ `;
1079
+ }
1080
+
1081
+ /**
1082
+ * Show success message
1083
+ * @param {string} message - Success message
1084
+ */
1085
+ showSuccess(message) {
1086
+ this.showCompletion(message);
1087
+ }
1088
+
1089
+ /**
1090
+ * Show navigation suggestion
1091
+ * @param {Object} article - KB article
1092
+ */
1093
+ showNavigationSuggestion(article) {
1094
+ const body = this.widget.querySelector('.handhold-widget-body');
1095
+ if (body) {
1096
+ body.innerHTML = `
1097
+ <div class="handhold-clarification">
1098
+ <p class="handhold-clarification-question">
1099
+ Found a guide that might help:
1100
+ </p>
1101
+ <p class="handhold-suggestion-title">${article.title}</p>
1102
+ <p class="handhold-suggestion-excerpt">${article.excerpt || ''}</p>
1103
+ ${article.route_pattern ? `
1104
+ ${new Button({
1105
+ text: 'Go to that page',
1106
+ variant: ButtonVariant.PRIMARY,
1107
+ attributes: { 'onclick': `window.location.href='${article.route_pattern}'` }
1108
+ }).render()}
1109
+ ` : ''}
1110
+ </div>
1111
+ `;
1112
+ }
1113
+ }
1114
+
1115
+ /**
1116
+ * Confirm if user wants to continue session
1117
+ * @param {string} message - Confirmation message
1118
+ * @returns {Promise<boolean>}
1119
+ */
1120
+ confirmContinueSession(message) {
1121
+ // The confirm must open the panel (e.g. on boot restore) — mirror isOpen too.
1122
+ this.isOpen = true;
1123
+ this.currentView = 'confirm';
1124
+ this._syncState({ confirm: { message } });
1125
+ return new Promise((resolve) => {
1126
+ // Resolve on EITHER this widget's own buttons (standalone) OR an
1127
+ // hh:confirm:response event from a host UI (the React demo answers there).
1128
+ let settled = false;
1129
+ const onEvent = (answer) => finish(!!answer);
1130
+ const finish = (answer) => {
1131
+ if (settled) return;
1132
+ settled = true;
1133
+ eventBus.off('hh:confirm:response', onEvent);
1134
+ resolve(answer);
1135
+ };
1136
+ eventBus.on('hh:confirm:response', onEvent);
1137
+
1138
+ const body = this.widget.querySelector('.handhold-widget-body');
1139
+ if (body) {
1140
+ body.innerHTML = `
1141
+ <div class="handhold-clarification">
1142
+ <p class="handhold-clarification-question">${message}</p>
1143
+ <div class="handhold-confirm-actions">
1144
+ ${new Button({
1145
+ text: 'No, start over',
1146
+ variant: ButtonVariant.SECONDARY,
1147
+ attributes: { 'data-confirm': 'no' }
1148
+ }).render()}
1149
+ ${new Button({
1150
+ text: 'Yes, continue',
1151
+ variant: ButtonVariant.PRIMARY,
1152
+ attributes: { 'data-confirm': 'yes' }
1153
+ }).render()}
1154
+ </div>
1155
+ </div>
1156
+ `;
1157
+
1158
+ const handleClick = (e) => {
1159
+ if (e.target.dataset.confirm) {
1160
+ body.removeEventListener('click', handleClick);
1161
+ finish(e.target.dataset.confirm === 'yes');
1162
+ }
1163
+ };
1164
+
1165
+ body.addEventListener('click', handleClick);
1166
+ }
1167
+ });
1168
+ }
1169
+
1170
+ /**
1171
+ * Destroy the widget
1172
+ */
1173
+ destroy() {
1174
+ if (this.container) {
1175
+ this.container.remove();
1176
+ this.container = null;
1177
+ this.widget = null;
1178
+ this.helpButton = null;
1179
+ }
1180
+
1181
+ // Remove styles
1182
+ const styles = document.getElementById('handhold-theme-styles');
1183
+ if (styles) {
1184
+ styles.remove();
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ export default HelpWidget;