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.
- package/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- 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, '&')
|
|
680
|
+
.replace(/</g, '<')
|
|
681
|
+
.replace(/>/g, '>')
|
|
682
|
+
.replace(/"/g, '"')
|
|
683
|
+
.replace(/'/g, ''');
|
|
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;
|