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,1599 @@
1
+ /**
2
+ * Hand Hold SDK - Main Orchestrator Class
3
+ *
4
+ * Provides context-aware, in-app help by combining:
5
+ * - Intent menu inferred from current page UI
6
+ * - Conversational fallback for free-text questions
7
+ * - Visual UI guidance with element highlighting
8
+ * - Multi-page workflow tracking
9
+ *
10
+ * @version 1.0.0
11
+ */
12
+
13
+ import Config from './core/Config.js';
14
+ import { Logger } from './utils/Logger.js';
15
+ import EventBus from './utils/eventBus.js';
16
+ import APIClient from './api/APIClient.js';
17
+ import PageObserver from './observers/PageObserver.js';
18
+ import IntentExtractor from './intent/IntentExtractor.js';
19
+ import SessionManager from './session/SessionManager.js';
20
+ import HelpWidget from './ui/HelpWidget.js';
21
+ import UICoach from './ui/UICoach.js';
22
+ import AgentMode from './agent-mode/AgentMode.js';
23
+ import UiState from './core/UiState.js';
24
+ import EVENTS from './events.js';
25
+ import html2canvas from 'html2canvas';
26
+
27
+ class HandHold {
28
+ constructor() {
29
+ // Core components (initialized on init())
30
+ this.config = null;
31
+ this.logger = null;
32
+ this.apiClient = null;
33
+ this.pageObserver = null;
34
+ this.sessionManager = null;
35
+ this.helpWidget = null;
36
+ this.uiCoach = null;
37
+ this.agentMode = null;
38
+ // Single source of truth for the widget UI (Phase 2.1). Host renderers
39
+ // subscribe via HandHold.subscribe()/getState(); the legacy widget mirrors
40
+ // its view changes into it (HelpWidget._syncState).
41
+ this.uiState = new UiState();
42
+ // Bound so a host can pass them straight to useSyncExternalStore.
43
+ this.subscribe = this.subscribe.bind(this);
44
+ this.getState = this.getState.bind(this);
45
+
46
+ // State
47
+ this.initialized = false;
48
+ this.currentIntents = [];
49
+ this.isUpdating = false;
50
+ this.isMatchingStep = false;
51
+ this.lastStepAdvanceTime = 0;
52
+
53
+ // Host-agnostic step-completion detection. We attach a one-shot interaction
54
+ // listener to the CURRENT step's real target element so a step advances when
55
+ // the user actually performs the action — in ANY host, including one that
56
+ // renders its own UI (and therefore suppresses the SDK's own overlay). This
57
+ // is the single source of auto-advance; the visual highlight no longer
58
+ // carries its own onClick (which only fired when the SDK drew the overlay
59
+ // and skipped the first step entirely).
60
+ this._watchedEl = null;
61
+ this._watchHandlers = null;
62
+ // Timestamp of the last navigation-type step completion. Lets
63
+ // _onNavigationChanged tell an EXPECTED step navigation (clicked the
64
+ // highlighted "Click X" element) from the user wandering off (which prompts
65
+ // to keep/exit the workflow).
66
+ this._stepNavAt = 0;
67
+ }
68
+
69
+ /**
70
+ * Initialize the SDK
71
+ * @param {Object} options - Configuration options
72
+ * @param {string} options.apiKey - API key for authentication (required)
73
+ * @param {string} options.baseUrl - Backend API base URL (required)
74
+ * @param {string} [options.theme='light'] - UI theme ('light' or 'dark')
75
+ * @param {string} [options.position='bottom-right'] - Widget position
76
+ * @param {string} [options.primaryColor='#2196F3'] - Primary color
77
+ * @param {number} [options.minHighlightConfidence=0.85] - Min confidence for highlighting
78
+ * @param {number} [options.sessionInactivityTimeout=600000] - Session timeout (ms)
79
+ * @param {boolean} [options.enableMultiPageTracking=true] - Enable multi-page flows
80
+ * @param {boolean} [options.debug=false] - Enable debug logging
81
+ * @returns {Promise<void>}
82
+ */
83
+ async init(options) {
84
+ if (this.initialized) {
85
+ console.warn('[HandHold] Already initialized. Call destroy() first to reinitialize.');
86
+ return;
87
+ }
88
+
89
+ try {
90
+ // Initialize configuration
91
+ this.config = new Config(options);
92
+
93
+ // Initialize logger
94
+ this.logger = new Logger({
95
+ prefix: 'HandHold',
96
+ level: this.config.get('logLevel'),
97
+ enabled: this.config.get('debug')
98
+ });
99
+
100
+ this.logger.info('Initializing Hand Hold SDK...');
101
+
102
+ // Initialize API client
103
+ this.apiClient = new APIClient(this.config);
104
+
105
+ // Initialize page observer
106
+ this.pageObserver = new PageObserver(this.config);
107
+
108
+ // Initialize session manager
109
+ this.sessionManager = new SessionManager(this.config);
110
+
111
+ // Initialize UI coach
112
+ this.uiCoach = new UICoach(this.config);
113
+
114
+ // Initialize help widget and wire it to the single UI-state store.
115
+ this.helpWidget = new HelpWidget(this.config);
116
+ this.helpWidget.attachState(this.uiState);
117
+ // Re-broadcast state changes as a single event for any event-based consumer.
118
+ this.uiState.subscribe((snapshot) => EventBus.emit(EVENTS.STATE_CHANGED, snapshot));
119
+
120
+ // Initialize agent mode (H5) — feature-flagged, off by default.
121
+ // "Show me"/"Explain" behavior is untouched when disabled.
122
+ if (this.config.get('agentMode')) {
123
+ this.agentMode = new AgentMode({
124
+ config: this.config,
125
+ eventBus: EventBus,
126
+ sessionManager: this.sessionManager
127
+ });
128
+ }
129
+
130
+ // Setup event listeners
131
+ this._setupEventListeners();
132
+
133
+ // Start page observation
134
+ this.pageObserver.observe();
135
+
136
+ // Render help widget first (needed for session restoration)
137
+ this.helpWidget.render();
138
+ this.uiCoach.enableSelectionExplain();
139
+
140
+ // Restore agent/guidance state. A host that renders the widget itself
141
+ // (React demo) sets `deferRestore` and calls restoreSession() AFTER it has
142
+ // patched its renderer — otherwise the session-restore "continue?" confirm
143
+ // is drawn by the unpatched (and about-to-be-hidden) legacy widget and
144
+ // init blocks on it (Phase 1.4).
145
+ if (!this.config.get('deferRestore')) {
146
+ await this.restoreSession();
147
+ }
148
+
149
+ this.initialized = true;
150
+ this.logger.info('Hand Hold SDK initialized successfully');
151
+
152
+ // Emit ready event
153
+ EventBus.emit('handhold:ready');
154
+
155
+ } catch (error) {
156
+ console.error('[HandHold] Initialization failed:', error);
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Restore agent/guidance state after boot. Separated from init() so a host
163
+ * that owns the widget rendering can patch its renderer first (deferRestore),
164
+ * then call this so the confirm is drawn by the host UI, not the legacy widget.
165
+ *
166
+ * Cross-page agent resume (H7) runs BEFORE the "Show me" session restore and
167
+ * takes precedence: if a "Do it for me" run was mid-flight when the page
168
+ * full-reloaded, resume it and SKIP the guidance-session prompt (declining it
169
+ * would end the session and delete the agent checkpoint). The resumed run
170
+ * continues in the background; boot does not block on it.
171
+ * @returns {Promise<void>}
172
+ */
173
+ async restoreSession() {
174
+ let agentResumed = false;
175
+ if (this.agentMode) {
176
+ try {
177
+ const r = await this.agentMode.resumeIfCheckpointed();
178
+ agentResumed = !!(r && r.resumed);
179
+ } catch (error) {
180
+ this.logger.error('Agent resume failed:', error);
181
+ }
182
+ }
183
+ if (!agentResumed) {
184
+ await this._restoreSession();
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Setup internal event listeners
190
+ * @private
191
+ */
192
+ _setupEventListeners() {
193
+ // Help button clicked
194
+ EventBus.on('help:open', () => this._onHelpOpen());
195
+
196
+ // Intent selected from menu
197
+ EventBus.on('intent:selected', (intent) => this._onIntentSelected(intent));
198
+
199
+ // Free text query submitted
200
+ EventBus.on('query:submitted', (query) => this._onQuerySubmitted(query));
201
+
202
+ // Navigation changed
203
+ EventBus.on('navigation:changed', (data) => this._onNavigationChanged(data));
204
+
205
+ // Step marked as done
206
+ EventBus.on('step:done', () => this._onStepDone());
207
+
208
+ // Step skipped
209
+ EventBus.on('step:skip', () => this._onStepSkip());
210
+
211
+ // Session close requested (end the workflow)
212
+ EventBus.on('session:close', () => this._onSessionClose());
213
+
214
+ // Minimize requested (hide panel, KEEP the workflow so reopening resumes it)
215
+ EventBus.on(EVENTS.HELP_MINIMIZE, () => this._onMinimize());
216
+
217
+ // Re-show the current step's on-page hint (re-resolve + re-highlight + re-arm)
218
+ EventBus.on(EVENTS.HELP_SHOW_HINT, () => this._showCurrentStep());
219
+
220
+ // DOM changes (debounced by PageObserver)
221
+ EventBus.on('page:dom_changed', () => this._onDomChanged());
222
+
223
+ // Show UI for specific step
224
+ EventBus.on('step:show_ui', (data) => this._onShowUI(data));
225
+
226
+ EventBus.on('selection:explain', (data) => this._onSelectionExplain(data));
227
+
228
+ // Handle highlight request (emitted by HelpWidget when selector is already known)
229
+ EventBus.on('ui:highlight_request', async (data) => {
230
+ const { selector, stepId } = data;
231
+ const session = this.sessionManager.getActiveSession();
232
+ const step = session?.steps?.[stepId];
233
+
234
+ // await the real outcome (Phase 5.3) — resolves false if the element is
235
+ // missing OR detaches before the highlight builds, so the fallback fires.
236
+ // No onClick here: completion detection is owned by the host-agnostic
237
+ // watcher (_watchStepElement) so it works even when a host suppresses the
238
+ // SDK overlay, and clicking never advances twice.
239
+ const found = await this.uiCoach.highlightElement(selector, {
240
+ message: step?.text,
241
+ showArrow: true,
242
+ showTooltip: true,
243
+ showDoneButton: true,
244
+ });
245
+
246
+ if (found) {
247
+ this._watchStepElement(document.querySelector(selector), step);
248
+ } else {
249
+ // If selector is stale and element wasn't found, fall back to API-based matching
250
+ EventBus.emit('step:show_ui', { stepIndex: stepId });
251
+ }
252
+ });
253
+
254
+ // ---- Agent mode (H5): engine events -> widget UI + best-effort spotlight ----
255
+ // AgentMode itself listens for 'agent:start' (emitted by the widget's
256
+ // "Do it for me" button) and 'agent:stop'. These listeners only render.
257
+ EventBus.on('agent:activity', (ev) => {
258
+ if (!this.agentMode) return;
259
+ this.helpWidget.addAgentActivity(ev);
260
+ // Reserve space so the fixed built-in panel doesn't occlude page content
261
+ // from the agent (any site). run_start (fresh) or resuming (after reload).
262
+ if (ev?.type === 'run_start' || ev?.type === 'resuming') {
263
+ this._setAgentReflow(true);
264
+ }
265
+ if (ev?.type === 'executing' && ev.targetSelector) {
266
+ // Spotlight what the agent is about to act on (best-effort: a stale
267
+ // selector just logs a warning inside UICoach).
268
+ this.uiCoach.highlightElement(ev.targetSelector, {
269
+ message: ev.nextGoal || 'Working…',
270
+ showArrow: true,
271
+ showTooltip: Boolean(ev.nextGoal),
272
+ showDoneButton: false
273
+ });
274
+ }
275
+ });
276
+
277
+ EventBus.on('agent:confirm', ({ pending, respond }) => {
278
+ if (!this.agentMode) return;
279
+ this.helpWidget.showAgentConfirm(pending, respond);
280
+ });
281
+
282
+ EventBus.on('agent:ask', ({ question, respond }) => {
283
+ if (!this.agentMode) return;
284
+ this.helpWidget.showAgentAsk(question, respond);
285
+ });
286
+
287
+ EventBus.on('agent:done', (result) => {
288
+ if (!this.agentMode) return;
289
+ this.uiCoach.clearHighlight();
290
+ this.helpWidget.showAgentDone(result);
291
+ this._setAgentReflow(false);
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Agent-mode reflow (works on ANY site). The built-in widget is a fixed
297
+ * right-side panel; during an agent run it visually OCCLUDES page content, and
298
+ * the agent's DOM engine treats covered elements as not-visible
299
+ * (elementFromPoint), so it can't see/click them. Reserve space on the right
300
+ * (a margin on <html>) so the page reflows left and every element stays
301
+ * visible — released when the run ends.
302
+ *
303
+ * Skipped when the built-in widget isn't visible: a custom host that hides it
304
+ * (e.g. the React demo) owns its own layout and docks its own panel.
305
+ * @param {boolean} active
306
+ * @private
307
+ */
308
+ _setAgentReflow(active) {
309
+ if (typeof document === 'undefined') return;
310
+ const html = document.documentElement;
311
+
312
+ if (!active) {
313
+ html.style.transition = 'margin-right 0.3s ease';
314
+ html.style.removeProperty('margin-right');
315
+ return;
316
+ }
317
+
318
+ const container = this.helpWidget?.container;
319
+ if (!container) return;
320
+ // A host that hides the built-in widget handles its own layout.
321
+ const computed = (typeof window !== 'undefined' && window.getComputedStyle)
322
+ ? window.getComputedStyle(container)
323
+ : null;
324
+ if (container.style.display === 'none' || (computed && computed.display === 'none')) {
325
+ return;
326
+ }
327
+
328
+ // Reserve the panel width (.handhold-widget is 380px) + a gap. Measure when
329
+ // possible; fall back to the known CSS width (jsdom returns 0 for layout).
330
+ const panel = container.querySelector('.handhold-widget');
331
+ const measured = panel && panel.getBoundingClientRect ? panel.getBoundingClientRect().width : 0;
332
+ const reserve = Math.ceil(measured || 380) + 40;
333
+ html.style.transition = 'margin-right 0.3s ease';
334
+ html.style.marginRight = `${reserve}px`;
335
+ }
336
+
337
+ /**
338
+ * Handle show UI request for a step
339
+ * @private
340
+ * @param {Object} data - { stepIndex }
341
+ */
342
+ async _onShowUI(data) {
343
+ const { stepIndex } = data;
344
+ this.logger.debug('Show UI requested for step:', stepIndex);
345
+
346
+ const session = this.sessionManager.getActiveSession();
347
+ if (!session) return;
348
+
349
+ const step = session.steps[stepIndex];
350
+ if (!step) return;
351
+
352
+ try {
353
+ this.helpWidget.showLoading();
354
+
355
+ // Scan page
356
+ const pageContext = this.pageObserver.scanPage();
357
+
358
+ // Request matching for this specific step
359
+ const matchResult = await this.apiClient.matchElement({
360
+ step_description: step.text,
361
+ original_selector: step.selector,
362
+ route: pageContext.route,
363
+ ui_elements: pageContext.ui_elements,
364
+ session_id: session.session_id,
365
+ intent: session.intent,
366
+ steps: session.steps,
367
+ current_step_index: stepIndex
368
+ });
369
+
370
+ this.helpWidget.showGuidanceTooltip(session); // Restore view
371
+
372
+ if (matchResult.found && matchResult.confidence >= this.config.get('minHighlightConfidence')) {
373
+ // Update step with new selector
374
+ this.sessionManager.updateStep(stepIndex, {
375
+ selector: matchResult.selector,
376
+ confidence: matchResult.confidence,
377
+ match_type: matchResult.match_type
378
+ });
379
+
380
+ // Highlight (visual only). Detection is owned by the watcher below.
381
+ this.uiCoach.highlightElement(matchResult.selector, {
382
+ message: step.text,
383
+ showArrow: true,
384
+ showTooltip: true,
385
+ showDoneButton: true,
386
+ });
387
+ this._watchStepElement(document.querySelector(matchResult.selector), step);
388
+
389
+ } else {
390
+ this.uiCoach.clearHighlight();
391
+ this._unwatchStepElement();
392
+ this.helpWidget.showError(`Could not find element for: "${step.text}" on this page.`);
393
+ }
394
+
395
+ } catch (error) {
396
+ this.logger.error('Error showing UI:', error);
397
+ this.helpWidget.showGuidanceTooltip(session);
398
+ this.helpWidget.showError('Failed to locate element.');
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Handle help button click
404
+ * @private
405
+ */
406
+ async _onHelpOpen() {
407
+ this.logger.debug('Help opened');
408
+
409
+ // Restore ONLY an active session. A completed/ended session lingering in
410
+ // storage must not reappear as a live workflow (Phase 1.3) — end it and
411
+ // fall through to a fresh intent menu.
412
+ const session = this.sessionManager.getActiveSession();
413
+ if (session && session.status === 'active') {
414
+ // Resume the workflow where it was left off (e.g. after Close = minimize)
415
+ // and re-arm on-page detection + the highlight for the current step.
416
+ this.helpWidget.showGuidanceTooltip(session);
417
+ this._showCurrentStep();
418
+ return;
419
+ }
420
+ if (session) {
421
+ this.sessionManager.endSession('completed');
422
+ }
423
+
424
+ try {
425
+ // Scan current page
426
+ const pageContext = this.pageObserver.scanPage();
427
+ this.logger.debug('Page scanned:', pageContext);
428
+
429
+ // Extract intents (client-side, instant)
430
+ this.currentIntents = IntentExtractor.extract(
431
+ pageContext.ui_elements,
432
+ pageContext.route,
433
+ { maxIntents: this.config.get('maxIntents') }
434
+ );
435
+
436
+ this.logger.debug('Intents extracted:', this.currentIntents);
437
+
438
+ // Show intent menu
439
+ this.helpWidget.showIntentMenu(this.currentIntents);
440
+
441
+ } catch (error) {
442
+ this.logger.error('Error opening help:', error);
443
+ this.helpWidget.showError('Failed to load help. Please try again.');
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Handle intent selection
449
+ * @private
450
+ * @param {Object} intent - Selected intent
451
+ */
452
+ async _onIntentSelected(intent) {
453
+ this.logger.debug('Intent selected:', intent);
454
+
455
+ try {
456
+ this.helpWidget.showLoading();
457
+
458
+ // Get page context and UI elements
459
+ const pageContext = this.pageObserver.scanPage();
460
+
461
+ // Capture screenshot for visual context
462
+ const screenshot = await this._captureScreenshot();
463
+
464
+ // Generate or get session ID
465
+ const sessionId = this.sessionManager.getSessionId();
466
+
467
+ // Request guidance from backend
468
+ const guidance = await this.apiClient.getGuidance({
469
+ intent: intent.key,
470
+ route: pageContext.route,
471
+ page_context: {
472
+ route: pageContext.route,
473
+ title: pageContext.title,
474
+ headings: pageContext.headings || []
475
+ },
476
+ ui_elements: pageContext.ui_elements,
477
+ session_id: sessionId,
478
+ screenshot: screenshot
479
+ });
480
+
481
+ this.logger.debug('Guidance received:', guidance);
482
+
483
+ // Start session
484
+ this.sessionManager.startSession({
485
+ session_id: guidance.session_id, // Use backend-provided session ID
486
+ intent: intent.key,
487
+ intent_label: intent.label,
488
+ steps: guidance.steps,
489
+ original_route: pageContext.route,
490
+ summary: guidance.summary
491
+ });
492
+
493
+ // Show guidance tooltip
494
+ this.helpWidget.showGuidanceTooltip(guidance);
495
+
496
+ // Resolve + highlight + ARM completion detection for the first step. This
497
+ // was previously skipped ("just show the list"), which is why doing the
498
+ // first action never advanced it — nothing was listening on the target
499
+ // element. Now every step, including the first, is watched.
500
+ this._showCurrentStep();
501
+
502
+ } catch (error) {
503
+ this.logger.error('Error getting guidance:', error);
504
+
505
+ if (error.status === 404) {
506
+ this.helpWidget.showError(
507
+ 'No help article found for this action. Please contact support.'
508
+ );
509
+ } else {
510
+ this.helpWidget.showError(
511
+ 'Failed to load guidance. Please try again or contact support.'
512
+ );
513
+ }
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Handle free text query submission
519
+ * @private
520
+ * @param {string} query - User query
521
+ */
522
+ async _onQuerySubmitted(query) {
523
+ this.logger.debug('Query submitted:', query);
524
+
525
+ try {
526
+ this.helpWidget.showLoading();
527
+
528
+ const pageContext = this.pageObserver.scanPage();
529
+
530
+ // Get available intents for this page
531
+ const availableIntents = this.currentIntents.length > 0
532
+ ? this.currentIntents.map(i => i.key)
533
+ : IntentExtractor.extract(pageContext.ui_elements, pageContext.route)
534
+ .map(i => i.key);
535
+
536
+ // Try to match query to existing intents first (client-side)
537
+ const localMatch = IntentExtractor.matchQueryToIntent(query, this.currentIntents);
538
+
539
+ if (localMatch && localMatch.confidence >= 0.8) {
540
+ // Good local match, use it directly
541
+ this.logger.debug('Local intent match:', localMatch);
542
+ this._onIntentSelected(localMatch);
543
+ return;
544
+ }
545
+
546
+ // Capture screenshot for visual context
547
+ const screenshot = await this._captureScreenshot();
548
+
549
+ // Use LLM to extract intent
550
+ const result = await this.apiClient.extractIntent({
551
+ user_query: query,
552
+ route: pageContext.route,
553
+ available_intents: availableIntents,
554
+ ui_elements: pageContext.ui_elements, // Pass UI elements for better context matching
555
+ screenshot: screenshot
556
+ });
557
+
558
+ this.logger.debug('Intent extraction result:', result);
559
+
560
+ if (result.intent) {
561
+ // Intent found, get guidance
562
+ const matchedIntent = this.currentIntents.find(i => i.key === result.intent)
563
+ || { key: result.intent, label: this._formatIntentLabel(result.intent) };
564
+
565
+ this._onIntentSelected(matchedIntent);
566
+
567
+ } else if (result.clarifying_question) {
568
+ // Need clarification
569
+ const suggestedIntents = (result.suggested_intents || [])
570
+ .map(key => this.currentIntents.find(i => i.key === key) || {
571
+ key,
572
+ label: this._formatIntentLabel(key)
573
+ });
574
+
575
+ this.helpWidget.showClarification(
576
+ result.clarifying_question,
577
+ suggestedIntents
578
+ );
579
+
580
+ } else {
581
+ // No intent matched, search KB directly
582
+ await this._handleKBSearch(query);
583
+ }
584
+
585
+ } catch (error) {
586
+ this.logger.error('Error processing query:', error);
587
+ this.helpWidget.showError('Failed to process your question. Please try again.');
588
+ }
589
+ }
590
+
591
+ async _onSelectionExplain(data) {
592
+ const text = (data?.text || '').trim();
593
+ if (!text) return;
594
+
595
+ try {
596
+ // Use UI Coach Modal instead of Help Widget
597
+ this.uiCoach.showExplainLoading(text);
598
+
599
+ const pageContext = this.pageObserver.scanPage();
600
+
601
+ // Use dedicated explain endpoint
602
+ const result = await this.apiClient.explainTerm({
603
+ text: text,
604
+ route: pageContext.route,
605
+ page_title: pageContext.title,
606
+ context: "" // Can add surrounding text if available later
607
+ });
608
+
609
+ this.uiCoach.showExplainModal({
610
+ terms: result.terms,
611
+ summary: result.source === 'kb' ? 'From Knowledge Base' : 'AI Explanation'
612
+ });
613
+
614
+ } catch (error) {
615
+ this.logger.error('Selection explain failed:', error);
616
+ // Fallback to error modal with retry
617
+ this.uiCoach.showExplainError(text, () => {
618
+ this._onSelectionExplain({ text });
619
+ });
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Handle KB search for unmatched queries
625
+ * @private
626
+ * @param {string} query - Search query
627
+ */
628
+ async _handleKBSearch(query) {
629
+ try {
630
+ const results = await this.apiClient.searchKB(query, { limit: 3 });
631
+
632
+ if (results.results && results.results.length > 0) {
633
+ const topResult = results.results[0];
634
+
635
+ // Check if article is for a different route
636
+ if (topResult.route_pattern) {
637
+ this.helpWidget.showNavigationSuggestion({
638
+ title: topResult.title,
639
+ route: topResult.route_pattern,
640
+ excerpt: topResult.excerpt
641
+ });
642
+ } else {
643
+ // Show generic KB result
644
+ this.helpWidget.showError(
645
+ `Found help article: "${topResult.title}". Navigate to the relevant page to get step-by-step guidance.`
646
+ );
647
+ }
648
+ } else {
649
+ this.helpWidget.showError(
650
+ 'Could not find help for that question. Please contact support.'
651
+ );
652
+ }
653
+ } catch (error) {
654
+ this.logger.error('KB search failed:', error);
655
+ this.helpWidget.showError(
656
+ 'Could not search help articles. Please contact support.'
657
+ );
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Handle navigation changes (multi-page flow tracking)
663
+ * @private
664
+ * @param {Object} data - Navigation data
665
+ */
666
+ async _onNavigationChanged(data) {
667
+ const { route, previousRoute } = data;
668
+ this.logger.debug('Navigation changed:', { route, previousRoute });
669
+
670
+ // Clear any active selection explain button
671
+ this.uiCoach.disableSelectionExplain();
672
+ // Re-enable it after a short delay (to clear state but keep feature active)
673
+ setTimeout(() => this.uiCoach.enableSelectionExplain(), 100);
674
+
675
+ // Invalidate the page-scoped intent cache — it belongs to the previous
676
+ // page. Stale intents corrupt both the local match AND the available_intents
677
+ // sent to the LLM in _onQuerySubmitted (Phase 3.1). Next use re-scans the
678
+ // current page.
679
+ this.currentIntents = [];
680
+
681
+ const session = this.sessionManager.getActiveSession();
682
+
683
+ if (!session) {
684
+ // No active session, nothing to track
685
+ return;
686
+ }
687
+
688
+ // Record navigation
689
+ this.sessionManager.recordNavigation(route);
690
+
691
+ // NOTE (Phase 1.5): we intentionally do NOT auto-advance the step on
692
+ // navigation. Real step completion is driven by interaction on the
693
+ // highlighted element (see _watchStepElement).
694
+
695
+ // Decide whether this navigation is PART OF the workflow or the user
696
+ // wandering off. The reliable signal is: did a navigation-type step just
697
+ // complete? When the user clicks the highlighted element for a "Click X"
698
+ // step, the watcher fires step:done (stamping _stepNavAt) and THEN the app
699
+ // navigates — so that route change is expected and must not prompt. Any
700
+ // other navigation (a manual click on an unrelated nav item) is the user
701
+ // leaving, so we ask whether to keep the workflow.
702
+ //
703
+ // The old check used SessionManager.isInSameFlow(route), which keyed off
704
+ // session.original_route — so navigating BACK to the page the assistant was
705
+ // opened on (typically /dashboard) looked "in-flow" and never prompted,
706
+ // even for an unrelated workflow. That was the missed-prompt bug.
707
+ const stepDriven = this._stepNavAt && (Date.now() - this._stepNavAt) < 2000;
708
+ this._stepNavAt = 0; // consume: only the immediately-following nav is exempt
709
+
710
+ if (!stepDriven) {
711
+ const shouldContinue = await this.helpWidget.confirmContinueSession(
712
+ 'You’ve left this workflow. Keep the guided workflow running?'
713
+ );
714
+
715
+ if (!shouldContinue) {
716
+ this._onSessionClose();
717
+ return;
718
+ }
719
+
720
+ // Kept the workflow after a manual navigation — re-anchor the current
721
+ // step on this page (its target may live here). Best-effort. Step-driven
722
+ // navigations already re-anchor via _onStepDone's post-advance timeout.
723
+ this._showCurrentStep();
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Resolve, highlight, and ARM completion detection for the current step.
729
+ *
730
+ * This is the single entry point that makes a step "live": it re-resolves the
731
+ * step's target element against the CURRENT page when the stored selector was
732
+ * matched on a different route or no longer resolves (fixes the "Next points
733
+ * at the sidebar instead of Add Employee" bug), draws the visual highlight for
734
+ * hosts using the built-in widget, and attaches a host-agnostic interaction
735
+ * watcher so the step advances when the user actually performs the action —
736
+ * regardless of whether the host renders the SDK's own UI.
737
+ * @private
738
+ */
739
+ async _showCurrentStep() {
740
+ try {
741
+ await this._showCurrentStepInner();
742
+ } catch (error) {
743
+ // A step-resolution/highlight failure must never break the caller
744
+ // (intent-select, resume-on-open, advance). Degrade quietly.
745
+ this.logger?.error?.('Failed to show current step:', error);
746
+ }
747
+ }
748
+
749
+ /** @private */
750
+ async _showCurrentStepInner() {
751
+ const session = this.sessionManager.getActiveSession();
752
+ if (!session || session.status !== 'active') return;
753
+
754
+ const step = this.sessionManager.getCurrentStep();
755
+ if (!step) return;
756
+
757
+ const route = this.pageObserver.getRoute();
758
+ const minConfidence = this.config.get('minHighlightConfidence');
759
+
760
+ // A stored selector is trustworthy only if it was matched on THIS page and
761
+ // still resolves. Selectors from the backend are matched on the page where
762
+ // guidance was generated (session.original_route); reused verbatim on a
763
+ // later page they point at the wrong element.
764
+ const matchedRoute = step.selector_route || session.original_route;
765
+ const selectorUsable = !!step.selector
766
+ && (step.confidence === undefined || step.confidence >= minConfidence)
767
+ && matchedRoute === route
768
+ && this.pageObserver.elementExists(step.selector);
769
+
770
+ let selector = selectorUsable ? step.selector : await this._resolveStepSelector(step, route);
771
+
772
+ const element = selector ? document.querySelector(selector) : null;
773
+
774
+ if (!element) {
775
+ // Couldn't locate the element on this page — show the instruction as text
776
+ // so the user isn't stranded, and stop watching a stale element.
777
+ this._unwatchStepElement();
778
+ this.uiCoach.clearHighlight();
779
+ this.helpWidget.show();
780
+ this.helpWidget.showTextOnlyGuidance(
781
+ session,
782
+ 'Look for: ' + step.text
783
+ );
784
+ return;
785
+ }
786
+
787
+ // Visual highlight for built-in-widget hosts. A host that renders its own
788
+ // on-page indicator (showStepHighlight:false) suppresses it to avoid a
789
+ // duplicate — detection below is independent of the visual. No onClick:
790
+ // completion detection is the watcher's job, so it never double-advances.
791
+ if (this.config.get('showStepHighlight')) {
792
+ this.uiCoach.highlightElement(selector, {
793
+ message: step.text,
794
+ showArrow: true,
795
+ showTooltip: true,
796
+ showDoneButton: true,
797
+ showSkipButton: false,
798
+ });
799
+ } else {
800
+ this.uiCoach.clearHighlight();
801
+ }
802
+
803
+ // Arm host-agnostic completion detection on the real element.
804
+ this._watchStepElement(element, step);
805
+
806
+ // Keep the step list in sync (progress/highlight state).
807
+ this.helpWidget.updateTooltip(session);
808
+ }
809
+
810
+ /**
811
+ * Ask the backend to match the current step's described action to an element
812
+ * on the CURRENT page. Updates the step's selector and stamps the route it was
813
+ * matched against (selector_route) so we know when it later goes stale.
814
+ * @private
815
+ * @returns {Promise<string|null>} the matched selector, or null
816
+ */
817
+ async _resolveStepSelector(step, route) {
818
+ if (this.isMatchingStep) return null;
819
+ const session = this.sessionManager.getActiveSession();
820
+ if (!session) return null;
821
+
822
+ this.isMatchingStep = true;
823
+ try {
824
+ const pageContext = this.pageObserver.scanPage();
825
+ const matchResult = await this.apiClient.matchElement({
826
+ step_description: step.text,
827
+ original_selector: step.selector,
828
+ route,
829
+ ui_elements: pageContext.ui_elements,
830
+ session_id: session.session_id,
831
+ intent: session.intent,
832
+ steps: session.steps,
833
+ current_step_index: session.current_step,
834
+ });
835
+
836
+ this.logger.debug('Element match result:', matchResult);
837
+
838
+ if (matchResult.found && matchResult.confidence >= this.config.get('minHighlightConfidence')) {
839
+ this.sessionManager.updateStep(session.current_step, {
840
+ selector: matchResult.selector,
841
+ confidence: matchResult.confidence,
842
+ match_type: matchResult.match_type,
843
+ selector_route: route, // remember the page this selector is valid for
844
+ });
845
+ return matchResult.selector;
846
+ }
847
+ return null;
848
+ } catch (error) {
849
+ this.logger.error('Error re-matching element:', error);
850
+ return null;
851
+ } finally {
852
+ this.isMatchingStep = false;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Attach a one-shot completion watcher to the current step's real target
858
+ * element. Clicking a button/link, or typing into / toggling a field, emits
859
+ * step:done. This works in ANY host because it listens on the actual page
860
+ * element, not on the SDK's overlay.
861
+ * @private
862
+ */
863
+ _watchStepElement(element, step) {
864
+ this._unwatchStepElement();
865
+ if (!element) return;
866
+
867
+ this._watchedEl = element;
868
+ this._watchHandlers = [];
869
+
870
+ const fire = (ev) => {
871
+ // Remove the sibling listeners so the same interaction can't double-fire.
872
+ this._unwatchStepElement();
873
+ // A CLICK on the highlighted element may navigate (a nav link, or a button
874
+ // that routes). Mark it so _onNavigationChanged treats the imminent route
875
+ // change as EXPECTED step progress and doesn't prompt "leave the
876
+ // workflow?". Only real clicks on the highlighted element count — NOT the
877
+ // panel's "Next" button and NOT typing — so a manual navigation to an
878
+ // unrelated page still prompts.
879
+ if (ev && ev.type === 'click') {
880
+ this._stepNavAt = Date.now();
881
+ }
882
+ EventBus.emit(EVENTS.STEP_DONE);
883
+ };
884
+
885
+ const add = (type) => {
886
+ element.addEventListener(type, fire, { once: true });
887
+ this._watchHandlers.push([type, fire]);
888
+ };
889
+
890
+ const tag = element.tagName;
891
+ const isField = tag === 'INPUT' || tag === 'TEXTAREA' || element.isContentEditable;
892
+ if (isField) {
893
+ if (element.type === 'checkbox' || element.type === 'radio') {
894
+ add('change');
895
+ } else {
896
+ add('input');
897
+ }
898
+ } else {
899
+ add('click');
900
+ }
901
+ }
902
+
903
+ /** Detach the current step-completion watcher, if any. @private */
904
+ _unwatchStepElement() {
905
+ if (this._watchedEl && this._watchHandlers) {
906
+ for (const [type, fn] of this._watchHandlers) {
907
+ this._watchedEl.removeEventListener(type, fn);
908
+ }
909
+ }
910
+ this._watchedEl = null;
911
+ this._watchHandlers = null;
912
+ }
913
+
914
+ /**
915
+ * Back-compat shim: older call sites re-matched the current step on a new
916
+ * page. That responsibility now lives in _showCurrentStep (which also arms
917
+ * detection), so delegate.
918
+ * @private
919
+ */
920
+ async _rematchCurrentStep() {
921
+ return this._showCurrentStep();
922
+ }
923
+
924
+ /**
925
+ * Handle step marked as done
926
+ * @private
927
+ */
928
+ _onStepDone() {
929
+ if (this.isUpdating) return;
930
+
931
+ this.logger.debug('Step marked as done');
932
+ this.lastStepAdvanceTime = Date.now();
933
+
934
+ // Check if the current step action likely triggers navigation
935
+ const currentStep = this.sessionManager.getCurrentStep();
936
+ const isNavigationAction = currentStep && (
937
+ currentStep.text.toLowerCase().includes('click') ||
938
+ currentStep.text.toLowerCase().includes('navigate') ||
939
+ currentStep.text.toLowerCase().includes('select') ||
940
+ currentStep.selector
941
+ );
942
+
943
+ // NOTE: _stepNavAt (the "expected step navigation" marker) is set in the
944
+ // interaction watcher on a real CLICK of the highlighted element — NOT here.
945
+ // Advancing via the panel's "Next" button must not exempt a subsequent
946
+ // manual navigation from the "leave the workflow?" prompt.
947
+
948
+ const advanced = this.sessionManager.advanceStep();
949
+
950
+ if (!advanced) {
951
+ // All steps completed
952
+ this._onSessionComplete();
953
+ return;
954
+ }
955
+
956
+ // Update widget
957
+ const session = this.sessionManager.getActiveSession();
958
+ this.helpWidget.updateTooltip(session);
959
+
960
+ // Ensure widget is visible if not minimized
961
+ // We don't force show here if it was explicitly closed, but we should ensure content is updated
962
+
963
+ // If this was likely a navigation action, delay highlighting
964
+ // to give the page time to transition
965
+ if (isNavigationAction) {
966
+ this.logger.debug('Navigation action detected, delaying highlight...');
967
+
968
+ // Set a flag or just use a timeout to retry highlighting
969
+ // Ideally we would wait for page:dom_changed, but a simple timeout is safer fallback
970
+ setTimeout(() => {
971
+ // Re-resolve the next step against whatever page we're now on and arm it.
972
+ this._showCurrentStep();
973
+ }, 1000);
974
+ } else {
975
+ // Immediate highlight for non-navigation steps (e.g. typing)
976
+ this._showCurrentStep();
977
+ }
978
+ }
979
+
980
+ /**
981
+ * Handle step skipped
982
+ * @private
983
+ */
984
+ _onStepSkip() {
985
+ this.logger.debug('Step skipped');
986
+
987
+ // Same as done, just move to next step
988
+ this._onStepDone();
989
+ }
990
+
991
+ /**
992
+ * Handle session completion
993
+ * @private
994
+ */
995
+ async _onSessionComplete() {
996
+ if (this.isUpdating) return;
997
+ this.isUpdating = true;
998
+
999
+ this.logger.info('Session completed');
1000
+
1001
+ const session = this.sessionManager.getActiveSession();
1002
+
1003
+ if (session) {
1004
+ try {
1005
+ // Update backend
1006
+ await this.apiClient.updateSession({
1007
+ session_id: session.session_id,
1008
+ status: 'completed',
1009
+ current_step: session.current_step,
1010
+ total_steps: session.total_steps
1011
+ });
1012
+
1013
+ // Update local session to stop polling but keep active
1014
+ this.sessionManager.updateSession({ status: 'completed' });
1015
+
1016
+ } catch (error) {
1017
+ this.logger.error('Failed to update session status:', error);
1018
+ }
1019
+ }
1020
+
1021
+ // Clear highlight + stop watching the completed step's element.
1022
+ this.uiCoach.clearHighlight();
1023
+ this._unwatchStepElement();
1024
+
1025
+ // Show the widget (in case it was closed/minimized) so user sees the completion screen
1026
+ this.helpWidget.show();
1027
+
1028
+ // Show completion screen
1029
+ this.helpWidget.showCompletion(session?.intent_label || session?.intent);
1030
+
1031
+ // Emit completion event
1032
+ EventBus.emit('handhold:completed', { intent: session?.intent });
1033
+
1034
+ this.isUpdating = false;
1035
+ }
1036
+
1037
+ /**
1038
+ * Handle session close
1039
+ * @private
1040
+ */
1041
+ async _onSessionClose() {
1042
+ this.logger.debug('Session close requested');
1043
+
1044
+ const session = this.sessionManager.getActiveSession();
1045
+
1046
+ // Tear down SYNCHRONOUSLY, before any await. A caller often exits then
1047
+ // immediately reopens (handleConfirmExit: exitWorkflow(); openHelp()). If we
1048
+ // awaited the backend update first, endSession() would run later and the
1049
+ // reopen would still observe the session as active and RESUME the workflow
1050
+ // we just exited — the "stale workflow bleeds into the next question" bug.
1051
+ this.uiCoach.clearHighlight();
1052
+ this._unwatchStepElement();
1053
+ this.helpWidget.hide();
1054
+ // Full reset so stale guidance can't bleed into the next question/menu.
1055
+ this.helpWidget.resetView();
1056
+ this.sessionManager.endSession();
1057
+
1058
+ // Emit close event
1059
+ EventBus.emit('handhold:closed', { intent: session?.intent });
1060
+
1061
+ // Best-effort backend update — must not gate the local teardown above.
1062
+ if (session) {
1063
+ try {
1064
+ await this.apiClient.updateSession({
1065
+ session_id: session.session_id,
1066
+ status: 'abandoned',
1067
+ current_step: session.current_step,
1068
+ total_steps: session.total_steps
1069
+ });
1070
+ } catch (error) {
1071
+ this.logger.error('Failed to update session status:', error);
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * Minimize the widget: hide the panel but KEEP the active workflow so
1078
+ * reopening (the trigger button / open()) resumes it where it left off.
1079
+ * This is what Close/✕ should do on an active workflow — ending the session
1080
+ * is reserved for an explicit "Exit workflow". Clears the on-page highlight
1081
+ * and detection while minimized so nothing lingers on the page.
1082
+ * @private
1083
+ */
1084
+ _onMinimize() {
1085
+ const session = this.sessionManager.getActiveSession();
1086
+ // Nothing to minimize to — behave like a plain close.
1087
+ if (!session || session.status !== 'active') {
1088
+ this._onSessionClose();
1089
+ return;
1090
+ }
1091
+ this.uiCoach.clearHighlight();
1092
+ this._unwatchStepElement();
1093
+ // Pure-visual hide; keeps currentView/currentGuidance so resume works.
1094
+ this.helpWidget.hide();
1095
+ }
1096
+
1097
+ /**
1098
+ * Handle DOM changes
1099
+ * @private
1100
+ */
1101
+ _onDomChanged() {
1102
+ // 1. If active highlight, verify element still exists
1103
+ if (this.uiCoach.isActive()) {
1104
+ const session = this.sessionManager.getActiveSession();
1105
+ if (session) {
1106
+ const currentStep = this.sessionManager.getCurrentStep();
1107
+ if (currentStep && currentStep.selector) {
1108
+ const exists = this.pageObserver.elementExists(currentStep.selector);
1109
+ if (!exists) {
1110
+ this.logger.debug('Highlighted element no longer exists');
1111
+ this.uiCoach.clearHighlight();
1112
+ this._rematchCurrentStep(this.pageObserver.getRoute());
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ // 2. If session active, check if we need to re-scan page (for navigation/content loading)
1119
+ /*
1120
+ // Disabled per user request to prevent infinite loops and 429s.
1121
+ // User prefers explicit "Show UI" or interaction.
1122
+ const session = this.sessionManager.getActiveSession();
1123
+ if (session && session.status === 'active') {
1124
+ // Use debounced check to avoid spamming API
1125
+ if (this._checkPageTimer) clearTimeout(this._checkPageTimer);
1126
+
1127
+ this._checkPageTimer = setTimeout(() => {
1128
+ this._checkPageStatus();
1129
+ }, 500); // 500ms debounce
1130
+ }
1131
+ */
1132
+ }
1133
+
1134
+ /**
1135
+ * Check page status and update guidance
1136
+ * @private
1137
+ */
1138
+ async _checkPageStatus() {
1139
+ const session = this.sessionManager.getActiveSession();
1140
+ if (!session) return;
1141
+
1142
+ try {
1143
+ const pageContext = this.pageObserver.scanPage(true); // Force rescan
1144
+
1145
+ const result = await this.apiClient.checkPageStatus({
1146
+ session_id: session.session_id,
1147
+ route: pageContext.route,
1148
+ ui_elements: pageContext.ui_elements
1149
+ });
1150
+
1151
+ this.logger.debug('Page status check:', result);
1152
+
1153
+ if (result.status === 'active' && result.action === 'highlight') {
1154
+ // Update step info with new selector
1155
+ if (result.step_info) {
1156
+ this.sessionManager.updateStep(session.current_step, {
1157
+ selector: result.selector,
1158
+ confidence: result.step_info.confidence,
1159
+ match_type: result.step_info.match_type
1160
+ });
1161
+ }
1162
+
1163
+ // Highlight element
1164
+ this._highlightCurrentStep();
1165
+ } else if (result.status === 'active' && result.action === 'stay') {
1166
+ // Show fallback message if provided
1167
+ if (result.fallback_message) {
1168
+ this.helpWidget.showTextOnlyGuidance(session, result.fallback_message);
1169
+ }
1170
+ }
1171
+
1172
+ } catch (error) {
1173
+ this.logger.error('Failed to check page status:', error);
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Highlight current step element. Kept for back-compat; the resolve +
1179
+ * highlight + arm-detection logic now lives in _showCurrentStep.
1180
+ * @private
1181
+ */
1182
+ async _highlightCurrentStep() {
1183
+ return this._showCurrentStep();
1184
+ }
1185
+
1186
+ /**
1187
+ * Restore session after page load
1188
+ * @private
1189
+ */
1190
+ async _restoreSession() {
1191
+ if (!this.sessionManager.hasActiveSession()) {
1192
+ return;
1193
+ }
1194
+
1195
+ const session = this.sessionManager.getActiveSession();
1196
+ this.logger.debug('Found existing session:', session);
1197
+
1198
+ // Ask user if they want to continue
1199
+ const shouldContinue = await this.helpWidget.confirmContinueSession(
1200
+ 'You have an active guidance session. Continue where you left off?'
1201
+ );
1202
+
1203
+ if (!shouldContinue) {
1204
+ this._onSessionClose();
1205
+ return;
1206
+ }
1207
+
1208
+ // Restore session UI
1209
+ try {
1210
+ // Show guidance tooltip
1211
+ this.helpWidget.showGuidanceTooltip({
1212
+ intent: session.intent,
1213
+ summary: session.summary || '',
1214
+ steps: session.steps,
1215
+ current_step: session.current_step,
1216
+ total_steps: session.total_steps
1217
+ });
1218
+
1219
+ // Try to highlight current step
1220
+ await this._rematchCurrentStep(this.pageObserver.getRoute());
1221
+
1222
+ } catch (error) {
1223
+ this.logger.error('Error restoring session:', error);
1224
+ this._onSessionClose();
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Capture screenshot of the current page
1230
+ * @private
1231
+ * @returns {Promise<string|null>} Base64 encoded screenshot (no prefix)
1232
+ */
1233
+ async _captureScreenshot() {
1234
+ // Opt-out (default on). On modern-CSS sites (oklch/oklab — Tailwind v4)
1235
+ // html2canvas cannot parse the colors and stalls for seconds before
1236
+ // failing, hanging the widget on every question. Skip it entirely when
1237
+ // disabled; intent extraction works fine from the DOM without it.
1238
+ if (!this.config.get('enableScreenshots')) {
1239
+ return null;
1240
+ }
1241
+ try {
1242
+ // Ensure widget is hidden/minimized during capture if needed,
1243
+ // but ignoreElements handles it better visually.
1244
+
1245
+ const canvas = await html2canvas(document.body, {
1246
+ logging: false,
1247
+ useCORS: true,
1248
+ onclone: (clonedDoc) => {
1249
+ // Fix for html2canvas not supporting modern CSS colors like oklch
1250
+ try {
1251
+ const allElements = clonedDoc.querySelectorAll('*');
1252
+ const canvas = document.createElement('canvas');
1253
+ canvas.width = 1;
1254
+ canvas.height = 1;
1255
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
1256
+ const colorCache = new Map();
1257
+
1258
+ const resolveColor = (color) => {
1259
+ if (!color || typeof color !== 'string') return color;
1260
+ // Check for unsupported modern color functions
1261
+ if (!color.includes('oklch') && !color.includes('oklab') && !color.includes('lab(') && !color.includes('lch(')) return color;
1262
+
1263
+ if (colorCache.has(color)) return colorCache.get(color);
1264
+
1265
+ // Use canvas to convert color to RGBA
1266
+ ctx.clearRect(0, 0, 1, 1);
1267
+ ctx.fillStyle = color;
1268
+ ctx.fillRect(0, 0, 1, 1);
1269
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
1270
+ const rgba = `rgba(${r}, ${g}, ${b}, ${a / 255})`;
1271
+ colorCache.set(color, rgba);
1272
+ return rgba;
1273
+ };
1274
+
1275
+ allElements.forEach(el => {
1276
+ // Use defaultView from the cloned document (it should be mounted in an iframe)
1277
+ const view = el.ownerDocument.defaultView;
1278
+ if (!view) return;
1279
+
1280
+ const style = view.getComputedStyle(el);
1281
+
1282
+ // 1. Iterate through all computed style properties
1283
+ for (let i = 0; i < style.length; i++) {
1284
+ const prop = style[i];
1285
+ const val = style.getPropertyValue(prop);
1286
+
1287
+ // Check for any unsupported color function (oklch, oklab, lab, lch)
1288
+ if (val && (val.includes('oklch') || val.includes('oklab') || val.includes('lab(') || val.includes('lch('))) {
1289
+ el.style.setProperty(prop, resolveColor(val), 'important');
1290
+ }
1291
+ }
1292
+
1293
+ // 2. Also check inline styles specifically
1294
+ if (el.style) {
1295
+ for (let i = 0; i < el.style.length; i++) {
1296
+ const prop = el.style[i];
1297
+ const val = el.style.getPropertyValue(prop);
1298
+ if (val && (val.includes('oklch') || val.includes('oklab') || val.includes('lab(') || val.includes('lch('))) {
1299
+ el.style.setProperty(prop, resolveColor(val), 'important');
1300
+ }
1301
+ }
1302
+ }
1303
+ });
1304
+ } catch (e) {
1305
+ console.warn('[HandHold] Failed to process colors in screenshot clone:', e);
1306
+ }
1307
+ },
1308
+ ignoreElements: (element) => {
1309
+ // Ignore the help widget and coaching elements
1310
+ return element.id === 'handhold-container' ||
1311
+ element.classList.contains('handhold-widget') ||
1312
+ element.classList.contains('handhold-overlay') ||
1313
+ element.classList.contains('handhold-highlight') ||
1314
+ element.classList.contains('handhold-arrow') ||
1315
+ element.classList.contains('handhold-spotlight') ||
1316
+ element.classList.contains('handhold-step-tooltip');
1317
+ }
1318
+ });
1319
+
1320
+ const dataUrl = canvas.toDataURL('image/png');
1321
+ // Return just the base64 part
1322
+ return dataUrl.split(',')[1];
1323
+ } catch (error) {
1324
+ this.logger.error('Screenshot capture failed:', error);
1325
+ return null;
1326
+ }
1327
+ }
1328
+
1329
+ /**
1330
+ * Format intent key to human-readable label
1331
+ * @private
1332
+ * @param {string} key - Intent key
1333
+ * @returns {string} Formatted label
1334
+ */
1335
+ _formatIntentLabel(key) {
1336
+ return key
1337
+ .split('_')
1338
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
1339
+ .join(' ');
1340
+ }
1341
+
1342
+ // ==================== Public API ====================
1343
+
1344
+ /**
1345
+ * Open the help widget
1346
+ */
1347
+ open() {
1348
+ if (!this.initialized) {
1349
+ console.warn('[HandHold] SDK not initialized. Call init() first.');
1350
+ return;
1351
+ }
1352
+
1353
+ this.helpWidget.show();
1354
+ this._onHelpOpen();
1355
+ }
1356
+
1357
+ /**
1358
+ * Close the help widget
1359
+ */
1360
+ close() {
1361
+ if (!this.initialized) return;
1362
+
1363
+ this._onSessionClose();
1364
+ }
1365
+
1366
+ /**
1367
+ * Toggle the help widget
1368
+ */
1369
+ toggle() {
1370
+ if (!this.initialized) return;
1371
+
1372
+ this.helpWidget.toggle();
1373
+ }
1374
+
1375
+ /**
1376
+ * Start guidance for a specific intent
1377
+ * @param {string} intentKey - Intent key (e.g., 'approve_payroll')
1378
+ */
1379
+ async startGuidance(intentKey) {
1380
+ if (!this.initialized) {
1381
+ console.warn('[HandHold] SDK not initialized. Call init() first.');
1382
+ return;
1383
+ }
1384
+
1385
+ // Find intent in current page intents
1386
+ let intent = this.currentIntents.find(i => i.key === intentKey);
1387
+
1388
+ if (!intent) {
1389
+ // Create intent object
1390
+ intent = {
1391
+ key: intentKey,
1392
+ label: this._formatIntentLabel(intentKey),
1393
+ confidence: 1.0
1394
+ };
1395
+ }
1396
+
1397
+ this.helpWidget.show();
1398
+ await this._onIntentSelected(intent);
1399
+ }
1400
+
1401
+ /**
1402
+ * Get current intents for the page
1403
+ * @returns {Array} Array of intent objects
1404
+ */
1405
+ getIntents() {
1406
+ if (!this.initialized) return [];
1407
+
1408
+ const pageContext = this.pageObserver.scanPage();
1409
+ return IntentExtractor.extract(pageContext.ui_elements, pageContext.route);
1410
+ }
1411
+
1412
+ /**
1413
+ * Check if there's an active guidance session
1414
+ * @returns {boolean}
1415
+ */
1416
+ hasActiveSession() {
1417
+ if (!this.initialized) return false;
1418
+
1419
+ return this.sessionManager.hasActiveSession();
1420
+ }
1421
+
1422
+ /**
1423
+ * Get the active session
1424
+ * @returns {Object|null} Session object or null
1425
+ */
1426
+ getActiveSession() {
1427
+ if (!this.initialized) return null;
1428
+
1429
+ return this.sessionManager.getActiveSession();
1430
+ }
1431
+
1432
+ /**
1433
+ * Get session progress
1434
+ * @returns {number} Progress percentage (0-100)
1435
+ */
1436
+ getProgress() {
1437
+ if (!this.initialized) return 0;
1438
+
1439
+ return this.sessionManager.getProgress();
1440
+ }
1441
+
1442
+ /**
1443
+ * Subscribe to SDK events
1444
+ * @param {string} event - Event name
1445
+ * @param {Function} callback - Callback function
1446
+ * @returns {Function} Unsubscribe function
1447
+ */
1448
+ on(event, callback) {
1449
+ return EventBus.on(event, callback);
1450
+ }
1451
+
1452
+ /**
1453
+ * Unsubscribe from SDK events
1454
+ * @param {string} event - Event name
1455
+ * @param {Function} callback - Callback function
1456
+ */
1457
+ off(event, callback) {
1458
+ EventBus.off(event, callback);
1459
+ }
1460
+
1461
+ /**
1462
+ * Destroy the SDK instance
1463
+ */
1464
+ destroy() {
1465
+ if (!this.initialized) return;
1466
+
1467
+ this.logger.info('Destroying Hand Hold SDK...');
1468
+
1469
+ // Clear any active session without notifying backend
1470
+ if (this.uiCoach) {
1471
+ this.uiCoach.destroy();
1472
+ }
1473
+
1474
+ if (this.helpWidget) {
1475
+ this.helpWidget.destroy();
1476
+ }
1477
+
1478
+ if (this.pageObserver) {
1479
+ this.pageObserver.disconnect();
1480
+ }
1481
+
1482
+ if (this.sessionManager) {
1483
+ this.sessionManager.endSession();
1484
+ }
1485
+
1486
+ // Clear event listeners
1487
+ EventBus.clear();
1488
+
1489
+ // Reset state
1490
+ this.config = null;
1491
+ this.logger = null;
1492
+ this.apiClient = null;
1493
+ this.pageObserver = null;
1494
+ this.sessionManager = null;
1495
+ this.helpWidget = null;
1496
+ this.uiCoach = null;
1497
+ this.currentIntents = [];
1498
+ this.initialized = false;
1499
+
1500
+ console.log('[HandHold] SDK destroyed');
1501
+ }
1502
+
1503
+ /**
1504
+ * Get SDK version
1505
+ * @returns {string} Version string
1506
+ */
1507
+ getVersion() {
1508
+ return '1.0.0';
1509
+ }
1510
+
1511
+ /**
1512
+ * Check if SDK is initialized
1513
+ * @returns {boolean}
1514
+ */
1515
+ isInitialized() {
1516
+ return this.initialized;
1517
+ }
1518
+
1519
+ // ============================================
1520
+ // UI state (Phase 2) — single source of truth
1521
+ // ============================================
1522
+
1523
+ /**
1524
+ * Subscribe to UI-state changes. Shaped for React's useSyncExternalStore:
1525
+ * pass HandHold.subscribe and HandHold.getState directly. The listener runs
1526
+ * on every state change.
1527
+ * @param {(snapshot: Object) => void} listener
1528
+ * @returns {() => void} unsubscribe
1529
+ */
1530
+ subscribe(listener) {
1531
+ return this.uiState.subscribe(listener);
1532
+ }
1533
+
1534
+ /** Current UI-state snapshot (stable reference until it changes). */
1535
+ getState() {
1536
+ return this.uiState.getSnapshot();
1537
+ }
1538
+
1539
+ // ============================================
1540
+ // Commands (Phase 2.2) — the public API a host calls
1541
+ // ============================================
1542
+ // App code should call these instead of emitting raw EventBus events, so the
1543
+ // SDK owns the contract. Internally they drive the same event-based handlers.
1544
+
1545
+ /**
1546
+ * Open the help widget (marks it open, scans the page, shows the menu).
1547
+ * Sets the WIDGET's isOpen — the source _syncState() reflects into UiState —
1548
+ * so the follow-up scan doesn't overwrite isOpen back to false.
1549
+ */
1550
+ openHelp() {
1551
+ if (this.helpWidget) this.helpWidget.isOpen = true;
1552
+ this.uiState.setState({ isOpen: true });
1553
+ EventBus.emit(EVENTS.HELP_OPEN);
1554
+ }
1555
+
1556
+ /** Submit a free-text question. */
1557
+ submitQuery(query) { EventBus.emit(EVENTS.QUERY_SUBMITTED, query); }
1558
+
1559
+ /** Start a workflow for an intent. @param {{key:string,label?:string}} intent */
1560
+ startIntent(intent) { EventBus.emit(EVENTS.INTENT_SELECTED, intent); }
1561
+
1562
+ /** Mark the current guidance step done (advance). */
1563
+ markStepDone() { EventBus.emit(EVENTS.STEP_DONE); }
1564
+
1565
+ /** Go back a step. */
1566
+ goBackStep(stepIndex) { EventBus.emit(EVENTS.STEP_BACK, stepIndex); }
1567
+
1568
+ /** Answer the session-restore "continue?" confirm. @param {boolean} answer */
1569
+ respondToConfirm(answer) { EventBus.emit(EVENTS.CONFIRM_RESPONSE, answer); }
1570
+
1571
+ /** End / exit the active workflow session. */
1572
+ exitWorkflow() { EventBus.emit(EVENTS.SESSION_CLOSE); }
1573
+
1574
+ /**
1575
+ * Minimize the widget without ending the workflow. Reopening resumes it.
1576
+ * Use this for a Close/✕ affordance; use exitWorkflow() to actually end it.
1577
+ */
1578
+ minimize() { EventBus.emit(EVENTS.HELP_MINIMIZE); }
1579
+
1580
+ /**
1581
+ * Re-show the current step's on-page hint. Re-resolves the step's target
1582
+ * against the current page and re-anchors the highlight + completion watcher.
1583
+ * Use it for a "Show hint" affordance when the on-page pointer has been
1584
+ * dismissed or has scrolled/re-rendered away.
1585
+ */
1586
+ showHint() { EventBus.emit(EVENTS.HELP_SHOW_HINT); }
1587
+
1588
+ /** Start a "Do it for me" agent run. @param {Object} plan agent_plan */
1589
+ startAgent(plan) { EventBus.emit(EVENTS.AGENT_START, plan); }
1590
+
1591
+ /** Cooperatively stop the running agent. */
1592
+ stopAgent() { EventBus.emit(EVENTS.AGENT_STOP); }
1593
+ }
1594
+
1595
+ // Create singleton instance
1596
+ const handHoldInstance = new HandHold();
1597
+
1598
+ export default handHoldInstance;
1599
+ export { HandHold };