@sphoro/bugcatch-web-sdk 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sphoro/bugcatch-web-sdk",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
5
5
  "type": "module",
6
6
  "main": "dist/bugcatch.es.js",
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The predefined issues: questions the project's assistant can answer from the
3
+ * reporter's own account and close, offered in the widget as one-tap choices.
4
+ *
5
+ * The API decides the list (GET /ingest/agent-issues) — it is empty unless the
6
+ * project has the Sphoro Agent answering questions and a data source that can
7
+ * answer them. Every failure is an empty list: the tiles are a shortcut, and a
8
+ * widget that could not load them is still a widget that files reports.
9
+ *
10
+ * The agent ends its answer with SOLVED_QUESTION's wording; the widget draws
11
+ * Yes / No under it (widget.js messageHtml). Keep this pattern in step with
12
+ * agent-issues.ts SOLVED_QUESTION in the API and workers.
13
+ */
14
+ export const SOLVED_PROMPT = /Did this answer your question\?/i;
15
+
16
+ export async function fetchAgentIssues({ apiBase, apiKey, projectId }) {
17
+ try {
18
+ const res = await fetch(
19
+ `${apiBase}/ingest/agent-issues?projectId=${encodeURIComponent(projectId)}`,
20
+ { headers: { 'X-BugCatch-Key': apiKey } },
21
+ );
22
+ if (!res.ok) return [];
23
+ const body = await res.json();
24
+ return (Array.isArray(body?.issues) ? body.issues : [])
25
+ .filter((i) => i && typeof i.key === 'string' && typeof i.title === 'string')
26
+ .map((i) => ({ key: i.key, title: i.title, hint: typeof i.hint === 'string' ? i.hint : '' }));
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
package/src/identity.js CHANGED
@@ -202,7 +202,7 @@ export const KNOWN_OPTIONS = [
202
202
  'attachments', 'maxAttachments', 'maxAttachmentBytes', 'maxRecordingMs', 'beforeSend',
203
203
  'user', 'context', 'updates', 'updateToast', 'onUpdate', 'voice', 'onCallState',
204
204
  'onIncomingCall', 'onReportSubmitted', 'onAssistantPhase', 'onError', 'debug',
205
- 'drafts', 'knownIssues', 'release', 'environment', 'buildId',
205
+ 'drafts', 'knownIssues', 'agentIssues', 'release', 'environment', 'buildId',
206
206
  ];
207
207
 
208
208
  const CALLBACK_OPTIONS = [
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
20
20
  import { showIncomingCall } from './incoming-call.js';
21
21
  import { referenceFields } from './references.js';
22
+ import { fetchAgentIssues } from './agent-issues.js';
22
23
  import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
23
24
 
24
25
  // Replaced at build time from package.json. The guard is for anyone importing
@@ -500,7 +501,7 @@ class BugCatchClient {
500
501
  * copies of this would drift, and the half that drifted would be the one that
501
502
  * stopped attaching the console.
502
503
  */
503
- async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null }) {
504
+ async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null, issue = null }) {
504
505
  // This report's own context over the screen's, without keeping it.
505
506
  const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'report({ context })'))) : this.context;
506
507
  return {
@@ -541,6 +542,9 @@ class BugCatchClient {
541
542
  // description it is being sent anyway. A payload is a claim, and there is
542
543
  // no reason to make the join key one when the source text is right here.
543
544
  ...(references.length ? { references } : {}),
545
+ // A predefined issue the reporter picked (agentIssues()). The API keeps
546
+ // it only if this project offers it.
547
+ ...(issue ? { issue } : {}),
544
548
  };
545
549
  }
546
550
 
@@ -1136,7 +1140,7 @@ class BugCatchClient {
1136
1140
  /** File a bug with everything captured so far. */
1137
1141
  async report({
1138
1142
  title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
1139
- references = [], context = null,
1143
+ references = [], context = null, issue = null,
1140
1144
  } = {}) {
1141
1145
  if (!this.started) throw new Error('[BugCatch] call init() first.');
1142
1146
  if (typeof title !== 'string' || !title.trim()) throw new Error('[BugCatch] a title is required.');
@@ -1171,6 +1175,7 @@ class BugCatchClient {
1171
1175
  let payload = await this.#payload({
1172
1176
  title: title.trim(), description: typeof description === 'string' ? description : String(description ?? ''),
1173
1177
  priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
1178
+ issue: typeof issue === 'string' && issue ? issue.slice(0, 32) : null,
1174
1179
  });
1175
1180
 
1176
1181
  const screened = this.#screen(payload);
@@ -1291,6 +1296,31 @@ class BugCatchClient {
1291
1296
  return this.refFields;
1292
1297
  }
1293
1298
 
1299
+ /**
1300
+ * The questions this project's assistant can answer and close by itself —
1301
+ * "When is my next EMI due?", "Is my KYC complete?" — for a form to offer
1302
+ * as one-tap choices.
1303
+ *
1304
+ * const issues = await BugCatch.agentIssues();
1305
+ * // [{ key: 'emi', title: 'When is my next EMI due?', hint: 'The date and the amount' }]
1306
+ * BugCatch.report({ title: issues[0].title, issue: issues[0].key });
1307
+ *
1308
+ * The assistant looks the reporter up (identify() makes that instant),
1309
+ * answers from their record, and asks whether that settled it: yes resolves
1310
+ * the report, no hands it to the team. Empty unless the project has turned
1311
+ * this on and connected a data source that can answer. The widget calls it
1312
+ * itself; cached for the life of the page.
1313
+ */
1314
+ agentIssues() {
1315
+ if (this.agentIssueList) return this.agentIssueList;
1316
+ this.agentIssueList = fetchAgentIssues({
1317
+ apiBase: this.config.apiBase,
1318
+ apiKey: this.config.apiKey,
1319
+ projectId: this.config.projectId,
1320
+ });
1321
+ return this.agentIssueList;
1322
+ }
1323
+
1294
1324
  /**
1295
1325
  * Who this browser is signed in as on this project, from storage alone.
1296
1326
  *
package/src/widget.js CHANGED
@@ -15,6 +15,7 @@ import { draftKey, draftOperation, readDraft } from './drafts.js';
15
15
  import { readSession } from './session.js';
16
16
  import { sendMessage } from './updates.js';
17
17
  import { readReferences, validateReference } from './references.js';
18
+ import { SOLVED_PROMPT } from './agent-issues.js';
18
19
 
19
20
  const POSITIONS = {
20
21
  'bottom-right': 'bottom:20px;right:20px;',
@@ -438,6 +439,31 @@ textarea { min-height: 88px; resize: vertical; }
438
439
  .known .ask button { flex: none; }
439
440
  .known .err { margin: 6px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-danger); }
440
441
 
442
+ /* ---------- questions the assistant answers by itself ---------- */
443
+ .picks { margin-bottom: 16px; }
444
+ .picks .picks-h {
445
+ margin: 0 0 8px; font-size: 11px; font-weight: 680; letter-spacing: .06em;
446
+ text-transform: uppercase; color: var(--bc-faint);
447
+ }
448
+ .picks .picks-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; }
449
+ .picks .pick {
450
+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
451
+ padding: 9px 11px; text-align: left; cursor: pointer; font: inherit;
452
+ color: var(--bc-fg); background: var(--bc-subtle);
453
+ border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
454
+ transition: background 160ms, border-color 160ms, transform 160ms;
455
+ animation: bc-rise 240ms var(--bc-ease) both;
456
+ }
457
+ .picks .pick:hover { border-color: var(--bc-accent-line); background: var(--bc-accent-soft); }
458
+ .picks .pick[aria-pressed="true"] { border-color: var(--bc-accent); background: var(--bc-accent-soft); }
459
+ .picks .pick .t { font-size: 13px; font-weight: 620; line-height: 1.35; overflow-wrap: anywhere; }
460
+ .picks .pick .h { font-size: var(--bc-fs-xs); line-height: 1.4; color: var(--bc-muted); }
461
+ .picks .picks-note {
462
+ display: flex; gap: 6px; align-items: flex-start; margin: 8px 0 0;
463
+ font-size: 12px; line-height: 1.45; color: var(--bc-muted);
464
+ }
465
+ .picks .picks-note svg { flex: none; margin-top: 2px; color: var(--bc-ok); }
466
+
441
467
  /* ---------- who is reporting ----------
442
468
  A strip above the form rather than a screen in front of it: where signing in
443
469
  is optional this is a line the reporter may ignore, and it should read as
@@ -1341,6 +1367,9 @@ function openPanel({ root, host, dock, client, onClose }) {
1341
1367
  <div class="body">
1342
1368
  <div class="slot" role="alert"></div>
1343
1369
  <div class="whoami" hidden></div>
1370
+ <!-- The questions the assistant can answer from their account and
1371
+ close by itself. Drawn only when the project offers any. -->
1372
+ <div class="picks" hidden></div>
1344
1373
  <div class="known" hidden></div>
1345
1374
  <div class="compose">
1346
1375
  <div class="field">
@@ -1413,6 +1442,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1413
1442
  const whoami = $('.whoami');
1414
1443
  const refsBox = $('.refs');
1415
1444
  const known = $('.known');
1445
+ const picks = $('.picks');
1416
1446
  const historyBtn = $('[data-act="history"]');
1417
1447
 
1418
1448
  const draftStatus = document.createElement('div');
@@ -2075,6 +2105,7 @@ function openPanel({ root, host, dock, client, onClose }) {
2075
2105
  description: descInput.value,
2076
2106
  priority,
2077
2107
  references: answeredReference(),
2108
+ issue: pickedIssue?.key || null,
2078
2109
  attachments: items
2079
2110
  .filter((it) => it.status === 'ready')
2080
2111
  .map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
@@ -2221,6 +2252,57 @@ function openPanel({ root, host, dock, client, onClose }) {
2221
2252
  loadKnownIssues();
2222
2253
  titleInput.addEventListener('input', () => matchKnown(titleInput.value));
2223
2254
 
2255
+ /* ---------- questions the assistant answers by itself ---------- */
2256
+
2257
+ // One tap instead of a sentence: the report's title becomes the question,
2258
+ // and the assistant knows exactly what is being asked — it looks the
2259
+ // reporter up, answers from their account, and asks whether that settled
2260
+ // it. Tapping the chosen one again lets it go.
2261
+ let agentIssues = [];
2262
+ let pickedIssue = null;
2263
+
2264
+ function drawPicks() {
2265
+ if (!agentIssues.length) { picks.hidden = true; picks.innerHTML = ''; return; }
2266
+ picks.hidden = false;
2267
+ picks.innerHTML = `
2268
+ <p class="picks-h">Get an answer right away</p>
2269
+ <div class="picks-grid" role="group" aria-label="Common questions">
2270
+ ${agentIssues.map((issue) => `
2271
+ <button type="button" class="pick" data-pick="${escapeHtml(issue.key)}"
2272
+ aria-pressed="${pickedIssue?.key === issue.key}">
2273
+ <span class="t">${escapeHtml(issue.title)}</span>
2274
+ ${issue.hint ? `<span class="h">${escapeHtml(issue.hint)}</span>` : ''}
2275
+ </button>`).join('')}
2276
+ </div>
2277
+ ${pickedIssue ? `<p class="picks-note">${ICONS.check}<span>Our assistant will check your account and reply here. Add anything else below, then send.</span></p>` : ''}`;
2278
+ }
2279
+
2280
+ picks.addEventListener('click', (e) => {
2281
+ const btn = e.target.closest('[data-pick]');
2282
+ if (!btn) return;
2283
+ const issue = agentIssues.find((i) => i.key === btn.dataset.pick);
2284
+ if (!issue) return;
2285
+ const titleWasPick = pickedIssue && titleInput.value.trim() === pickedIssue.title;
2286
+ if (pickedIssue?.key === issue.key) {
2287
+ pickedIssue = null;
2288
+ if (titleWasPick) titleInput.value = '';
2289
+ } else {
2290
+ pickedIssue = issue;
2291
+ // Their own words are never overwritten — only an empty title, or the
2292
+ // one the previous tap put there.
2293
+ if (!titleInput.value.trim() || titleWasPick) titleInput.value = issue.title;
2294
+ }
2295
+ titleInput.dispatchEvent(new Event('input', { bubbles: true }));
2296
+ drawPicks();
2297
+ if (pickedIssue) descInput.focus();
2298
+ });
2299
+
2300
+ if (cfg.agentIssues !== false && typeof client.agentIssues === 'function') {
2301
+ client.agentIssues()
2302
+ .then((rows) => { agentIssues = rows; drawPicks(); })
2303
+ .catch(() => { /* no tiles is the safe answer to every failure */ });
2304
+ }
2305
+
2224
2306
  /* ---------- talk to the team ---------- */
2225
2307
 
2226
2308
  // Asked once, when the dialog opens, and the button stays hidden unless the
@@ -3329,12 +3411,19 @@ function messageHtml(message, state = '') {
3329
3411
  // confirmMessage). One tap sends it, and what comes back goes to the person
3330
3412
  // who now owns the report rather than to another automated round — so the
3331
3413
  // wording is "that's right" and "that's not it", not a yes/no question.
3414
+ // The Sphoro Agent's answer ends by asking whether it settled things
3415
+ // (SOLVED_PROMPT). Yes closes the report; No puts it in front of a person.
3332
3416
  const confirm = automated && /it goes to the same person/i.test(body)
3333
3417
  ? `<div class="quick" role="group" aria-label="Answer">
3334
3418
  <button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
3335
3419
  <button type="button" data-quick="No, that is not the problem">That's not it</button>
3336
3420
  </div>`
3337
- : '';
3421
+ : automated && SOLVED_PROMPT.test(body)
3422
+ ? `<div class="quick" role="group" aria-label="Did this answer your question?">
3423
+ <button type="button" data-quick="Yes, that answers it">${ICONS.check}Yes, that answers it</button>
3424
+ <button type="button" data-quick="No, I still need help">No, I still need help</button>
3425
+ </div>`
3426
+ : '';
3338
3427
 
3339
3428
  return `
3340
3429
  <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">