@sphoro/bugcatch-web-sdk 0.13.1 → 0.14.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/src/widget.js CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  startVoiceNote, voiceSupported,
12
12
  humanSize, humanDuration, normalizeMime, UPLOAD_MIMES, REPORT_MIMES,
13
13
  } from './capture.js';
14
+ import { draftKey, draftOperation, readDraft } from './drafts.js';
15
+ import { readSession } from './session.js';
14
16
  import { sendMessage } from './updates.js';
17
+ import { matchReferences, validateReference } from './references.js';
15
18
 
16
19
  const POSITIONS = {
17
20
  'bottom-right': 'bottom:20px;right:20px;',
@@ -354,6 +357,39 @@ textarea { min-height: 84px; resize: vertical; }
354
357
  .whoami .err { color: var(--bc-danger); }
355
358
  .whoami .code { letter-spacing: .28em; font-variant-numeric: tabular-nums; }
356
359
 
360
+ /* ---------- which record is this about ----------
361
+ Shaped like .whoami on purpose: both are the widget asking for something
362
+ rather than the reporter telling us something, and a reporter who has learnt
363
+ what the grey block means once should not have to learn it twice. Drawn only
364
+ after Send has been pressed and only when what they wrote did not already
365
+ answer it, so most people never see this at all. */
366
+ .refs {
367
+ margin-bottom: 14px; padding: 10px 12px;
368
+ font-size: 12.5px; line-height: 1.45; color: var(--bc-muted);
369
+ background: var(--bc-subtle); border-radius: 10px;
370
+ }
371
+ .refs .why { margin: 0 0 10px; font-size: 12.5px; line-height: 1.5; color: var(--bc-muted); }
372
+ .refs .row { display: flex; gap: 8px; align-items: stretch; }
373
+ .refs .row input { flex: 1; min-width: 0; }
374
+ .refs select {
375
+ flex: none; max-width: 45%; padding: 10px 10px; font-size: 13px;
376
+ color: var(--bc-fg); background: var(--bc-elevated);
377
+ border: 1px solid var(--bc-border); border-radius: 10px;
378
+ }
379
+ .refs select:focus {
380
+ outline: none; border-color: var(--bc-accent);
381
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 18%, transparent);
382
+ }
383
+ .refs .hint { margin: 8px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-faint); }
384
+ .refs .after { display: flex; gap: 12px; align-items: center; margin-top: 9px; }
385
+ .refs .err { color: var(--bc-danger); }
386
+ .refs .link {
387
+ margin-top: 9px; padding: 0; font-size: 12.5px; font-weight: 600; line-height: 1.45;
388
+ color: var(--bc-accent); background: none; border: none; cursor: pointer;
389
+ }
390
+ .refs .link:hover { text-decoration: underline; }
391
+ .refs .link:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; border-radius: 4px; }
392
+
357
393
  /* ---------- severity ---------- */
358
394
  .seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border-radius: 10px; }
359
395
  .seg button {
@@ -651,9 +687,9 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
651
687
 
652
688
  /* A call in progress, in place of the form.
653
689
  ---------------------------------------------------------------------------
654
- Deliberately not styled as a chat. What is on screen is a transcript of
655
- something being said out loud, and bubbles would invite the reporter to type
656
- into it — which is the one thing this surface cannot accept. */
690
+ Deliberately not styled as a chat. Nothing arrives here to read — the
691
+ conversation is happening out loud — so this is a state, a line of
692
+ reassurance about the report, and a way to hang up. */
657
693
  .call { padding: 20px 22px 14px; animation: bc-fade-in 240ms ease both; }
658
694
  .call-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
659
695
  .call-head strong { font-size: 14.5px; font-weight: 650; }
@@ -663,15 +699,14 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
663
699
  }
664
700
  .call-dot.off { background: var(--bc-faint); animation: none; }
665
701
  @keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
666
- /* Said before the first word is, because a voice answering is exactly when
667
- somebody assumes they have reached a person. */
702
+ /* Said while it is still ringing, because that is when somebody is deciding
703
+ whether it is worth waiting. */
668
704
  .call-note { margin: 0 0 12px; font-size: 12px; color: var(--bc-muted); line-height: 1.5; }
669
705
 
670
- /* ---------- a call to a person, on top of the report it is about ---------- */
671
- /* A bar rather than its own screen, unlike the agent call. There is nothing to
672
- read during this one — no transcript arrives, because nobody is transcribing
673
- it — and the thread underneath is exactly what both people are talking about,
674
- so taking it off screen would be taking away the only shared reference. */
706
+ /* ---------- the same call, on top of the report it is about ---------- */
707
+ /* A bar rather than its own screen, unlike the one the composer opens. The
708
+ thread underneath is exactly what both people are talking about, so taking it
709
+ off screen would be taking away the only shared reference. */
675
710
  .livecall {
676
711
  display: flex; align-items: center; gap: 9px;
677
712
  margin: 12px 0 0; padding: 9px 11px;
@@ -689,15 +724,6 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
689
724
  .livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
690
725
  .livecall.over { color: var(--bc-muted); }
691
726
  .call-note code { font-size: 11px; }
692
- .call-lines {
693
- max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px;
694
- padding: 10px 12px; border: 1px solid var(--bc-line); border-radius: 8px;
695
- background: var(--bc-surface-2, transparent);
696
- }
697
- .call-line { font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
698
- .call-line.agent { color: var(--bc-muted); }
699
- .call-line.you { color: var(--bc-ink); font-weight: 500; }
700
- .call-line.you::before { content: 'You: '; color: var(--bc-faint); font-weight: 400; }
701
727
  @media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
702
728
 
703
729
  .done { padding: 34px 24px 38px; text-align: center; animation: bc-fade-in 240ms ease both; }
@@ -963,6 +989,11 @@ function openPanel({ root, host, dock, client, onClose }) {
963
989
  <label for="bc-desc">Any detail that helps <span style="font-weight:450;color:var(--bc-faint)">(optional)</span></label>
964
990
  <textarea id="bc-desc" placeholder="I clicked Pay and nothing happened."></textarea>
965
991
  </div>
992
+ <!-- Drawn only when this project asks for an identifier and the report
993
+ does not already carry one. Between the description and the
994
+ severity because that is where it belongs in the reading order:
995
+ it is a question about what they just typed. -->
996
+ <div class="refs" hidden></div>
966
997
  <div class="field">
967
998
  <label id="bc-prio-l">Severity</label>
968
999
  <div class="seg" role="group" aria-labelledby="bc-prio-l">
@@ -994,7 +1025,7 @@ function openPanel({ root, host, dock, client, onClose }) {
994
1025
  most cannot. Drawn here rather than as a second launcher because it
995
1026
  is the same errand as Send report — the difference is only whether
996
1027
  the reporter would rather say it than type it. -->
997
- <button class="ghost" type="button" data-act="call" hidden>Talk to us</button>
1028
+ <button class="ghost" type="button" data-act="call" hidden>Talk to the team</button>
998
1029
  <button class="primary" type="button" data-act="send">Send report</button>
999
1030
  </div>
1000
1031
  </div>`;
@@ -1009,9 +1040,61 @@ function openPanel({ root, host, dock, client, onClose }) {
1009
1040
  const note = $('.note');
1010
1041
  const compose = $('.compose');
1011
1042
  const whoami = $('.whoami');
1043
+ const refsBox = $('.refs');
1012
1044
  const known = $('.known');
1013
1045
  const historyBtn = $('[data-act="history"]');
1014
1046
 
1047
+ const draftStatus = document.createElement('div');
1048
+ draftStatus.setAttribute('role', 'status');
1049
+ draftStatus.className = 'note';
1050
+ compose.prepend(draftStatus);
1051
+ const savedDraftKey = draftKey(cfg, readSession(cfg.projectId)?.email);
1052
+ let draftLoaded = cfg.drafts === false;
1053
+ let draftSubmitted = false;
1054
+ let draftWrites = Promise.resolve();
1055
+ let draftTimer;
1056
+ function persistDraft() {
1057
+ clearTimeout(draftTimer);
1058
+ if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
1059
+ const value = titleInput.value || $('#bc-desc').value || items.length ? {
1060
+ title: titleInput.value, description: $('#bc-desc').value, priority,
1061
+ items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
1062
+ } : null;
1063
+ draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
1064
+ .then(() => { draftStatus.textContent = value ? 'Draft saved on this device' : ''; })
1065
+ .catch(() => { draftStatus.textContent = 'Draft could not be saved. Keep this tab open.'; });
1066
+ return draftWrites;
1067
+ }
1068
+ function scheduleDraft() { clearTimeout(draftTimer); draftTimer = setTimeout(persistDraft, 250); }
1069
+ compose.addEventListener('input', scheduleDraft);
1070
+ compose.addEventListener('change', scheduleDraft);
1071
+ const onPageHide = () => { void persistDraft(); };
1072
+ window.addEventListener('pagehide', onPageHide);
1073
+ function reconnectUploads() { items.filter((it) => it.status === 'failed').forEach(upload); }
1074
+ window.addEventListener('online', reconnectUploads);
1075
+ if (cfg.drafts !== false) {
1076
+ // Disable composition briefly so a late storage read cannot replace new text.
1077
+ compose.inert = true;
1078
+ readDraft(savedDraftKey).then((draft) => {
1079
+ if (closed || !draft) return;
1080
+ draftStatus.innerHTML = 'Unfinished report on this device. <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
1081
+ draftStatus.querySelector('[data-resume]').onclick = () => {
1082
+ titleInput.value = draft.title || '';
1083
+ $('#bc-desc').value = draft.description || '';
1084
+ priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
1085
+ scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
1086
+ for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
1087
+ draftLoaded = true;
1088
+ void persistDraft();
1089
+ };
1090
+ draftStatus.querySelector('[data-discard]').onclick = () => {
1091
+ draftLoaded = true;
1092
+ void persistDraft();
1093
+ };
1094
+ }).catch(() => { draftStatus.textContent = 'Draft storage is unavailable. Keep this tab open.'; })
1095
+ .finally(() => { compose.inert = false; if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
1096
+ }
1097
+
1015
1098
  /* ---------- lifecycle ---------- */
1016
1099
 
1017
1100
  const previouslyFocused = document.activeElement;
@@ -1024,7 +1107,11 @@ function openPanel({ root, host, dock, client, onClose }) {
1024
1107
  let stopListening = null;
1025
1108
  function destroy() {
1026
1109
  if (closed) return;
1110
+ void persistDraft();
1027
1111
  closed = true;
1112
+ clearTimeout(draftTimer);
1113
+ window.removeEventListener('pagehide', onPageHide);
1114
+ window.removeEventListener('online', reconnectUploads);
1028
1115
  recorder?.cancel();
1029
1116
  // Stop listening before the DOM goes: a frame arriving after this would
1030
1117
  // redraw a panel that is no longer on the page.
@@ -1078,6 +1165,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1078
1165
  scrim.querySelectorAll('[data-prio]').forEach((btn) => {
1079
1166
  btn.addEventListener('click', () => {
1080
1167
  priority = btn.dataset.prio;
1168
+ scheduleDraft();
1081
1169
  scrim.querySelectorAll('[data-prio]').forEach((b) => {
1082
1170
  b.setAttribute('aria-pressed', String(b === btn));
1083
1171
  });
@@ -1092,8 +1180,8 @@ function openPanel({ root, host, dock, client, onClose }) {
1092
1180
 
1093
1181
  function refreshSendState() {
1094
1182
  const busy = items.some((it) => it.status === 'uploading');
1095
- sendBtn.disabled = sending || busy;
1096
- note.textContent = busy ? 'Waiting for the upload to finish…' : '';
1183
+ sendBtn.disabled = sending || busy || items.some((it) => it.status === 'failed');
1184
+ note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
1097
1185
  scrim.querySelectorAll('.tool').forEach((t) => {
1098
1186
  t.disabled = sending || items.length >= (cfg.maxAttachments ?? 5);
1099
1187
  });
@@ -1128,6 +1216,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1128
1216
  if (i >= 0) items.splice(i, 1);
1129
1217
  if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
1130
1218
  item.el.remove();
1219
+ void persistDraft();
1131
1220
  refreshSendState();
1132
1221
  }
1133
1222
 
@@ -1180,6 +1269,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1180
1269
  items.push(item);
1181
1270
  thumbs.appendChild(item.el);
1182
1271
  say('');
1272
+ void persistDraft();
1183
1273
  upload(item);
1184
1274
  }
1185
1275
 
@@ -1275,6 +1365,124 @@ function openPanel({ root, host, dock, client, onClose }) {
1275
1365
  titleInput.focus();
1276
1366
  }
1277
1367
 
1368
+ /* ---------- which record is this about ---------- */
1369
+
1370
+ // What this project asks for, and what the reporter has answered.
1371
+ //
1372
+ // The question is asked at Send and not before, which is the whole design of
1373
+ // it. A form that opens with "loan number" above "what went wrong" tells
1374
+ // somebody they have paperwork to do before they may complain, and most of
1375
+ // them will not — while the same question, asked once, after they have said
1376
+ // their piece, and only when what they said did not already contain the
1377
+ // answer, is a reasonable thing to be asked. Most reporters never see it: the
1378
+ // loan number was in the sentence.
1379
+ let refConfig = { policy: 'off', fields: [] };
1380
+ let refChoice = null; // which field they are answering
1381
+ let refValue = '';
1382
+ let refError = '';
1383
+ // Set once they have answered or explicitly skipped, so Send stops asking.
1384
+ let refSettled = false;
1385
+ // Set once we have said "that does not look right" about the current value.
1386
+ // Its own flag rather than reusing refSettled, which was the first attempt
1387
+ // and was a loop: the warning returns early, the next press re-validates the
1388
+ // same unchanged value, warns again, and the report can never be sent. One
1389
+ // warning per value, and then it is their call.
1390
+ let refWarned = false;
1391
+
1392
+ client.referenceFields()
1393
+ .then((cfg) => { refConfig = cfg; })
1394
+ .catch(() => { /* off is the safe answer to every failure */ });
1395
+
1396
+ /** What the report already carries, read out of what they typed. */
1397
+ function referencesInText() {
1398
+ if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
1399
+ return matchReferences(refConfig.fields, [titleInput.value, $('#bc-desc').value]);
1400
+ }
1401
+
1402
+ /** The answer to send, or an empty list. */
1403
+ function answeredReference() {
1404
+ if (!refChoice || !refValue.trim()) return [];
1405
+ return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
1406
+ }
1407
+
1408
+ function drawRefs() {
1409
+ if (!refChoice) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
1410
+ const field = refConfig.fields.find((f) => f.key === refChoice) || refConfig.fields[0];
1411
+ const optional = refConfig.policy !== 'required';
1412
+
1413
+ refsBox.hidden = false;
1414
+ refsBox.innerHTML = `
1415
+ <p class="why">${escapeHtml(
1416
+ optional
1417
+ ? 'One more thing, so we can find your record — this helps us answer faster.'
1418
+ : 'We need one of these to look this up.',
1419
+ )}</p>
1420
+ <div class="row">
1421
+ ${refConfig.fields.length > 1 ? `
1422
+ <select class="ref-kind" aria-label="Which detail you are giving">
1423
+ ${refConfig.fields.map((f) => `
1424
+ <option value="${escapeHtml(f.key)}" ${f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
1425
+ `).join('')}
1426
+ </select>` : ''}
1427
+ <input class="ref-value" type="${field.inputMode === 'email' ? 'email' : 'text'}"
1428
+ inputmode="${escapeHtml(field.inputMode === 'numeric' || field.inputMode === 'tel' ? 'numeric' : 'text')}"
1429
+ value="${escapeHtml(refValue)}"
1430
+ placeholder="${escapeHtml(field.placeholder || '')}"
1431
+ aria-label="${escapeHtml(field.label)}" />
1432
+ </div>
1433
+ ${field.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
1434
+ ${refError ? `<p class="after err">${escapeHtml(refError)}</p>` : ''}
1435
+ ${optional ? '<button type="button" class="link" data-act="ref-skip">I don\'t have this</button>' : ''}`;
1436
+
1437
+ const value = refsBox.querySelector('.ref-value');
1438
+ value.addEventListener('input', () => {
1439
+ refValue = value.value;
1440
+ refError = '';
1441
+ // A changed value has not been warned about yet.
1442
+ refWarned = false;
1443
+ });
1444
+ // Enter in the box is the same gesture as pressing Send, which is what
1445
+ // somebody who has just been asked one question expects it to be.
1446
+ value.addEventListener('keydown', (e) => {
1447
+ if (e.key === 'Enter') { e.preventDefault(); sendBtn.click(); }
1448
+ });
1449
+ refsBox.querySelector('.ref-kind')?.addEventListener('change', (e) => {
1450
+ refChoice = e.target.value;
1451
+ refError = '';
1452
+ drawRefs();
1453
+ refsBox.querySelector('.ref-value')?.focus();
1454
+ });
1455
+ refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
1456
+ // Skipping is answering. Without this the panel asks again on the next
1457
+ // press and the reporter is in a loop with a button that does nothing.
1458
+ refSettled = true;
1459
+ refChoice = null;
1460
+ refValue = '';
1461
+ refError = '';
1462
+ refWarned = false;
1463
+ drawRefs();
1464
+ say('');
1465
+ });
1466
+
1467
+ value.focus();
1468
+ }
1469
+
1470
+ /**
1471
+ * Whether Send should stop and ask.
1472
+ *
1473
+ * True at most once per report. The question is skippable on an `optional`
1474
+ * project and not on a `required` one, and on both the answer to "have we
1475
+ * already got one" is read from the text every time — somebody who types the
1476
+ * loan number into the description after being asked has answered the
1477
+ * question, and being asked again would be the widget not listening.
1478
+ */
1479
+ function needsReference() {
1480
+ if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
1481
+ if (referencesInText().length) return false;
1482
+ if (answeredReference().length) return false;
1483
+ return !refSettled || refConfig.policy === 'required';
1484
+ }
1485
+
1278
1486
  /* ---------- send ---------- */
1279
1487
 
1280
1488
  sendBtn.addEventListener('click', async () => {
@@ -1287,6 +1495,36 @@ function openPanel({ root, host, dock, client, onClose }) {
1287
1495
  return;
1288
1496
  }
1289
1497
 
1498
+ // Ask, if this project asks and nothing they wrote answered it. The first
1499
+ // press opens the question rather than sending; the second sends. That
1500
+ // costs one extra press on projects that want an identifier, and it is the
1501
+ // only shape that does not put paperwork in front of a person who came here
1502
+ // to say something is broken.
1503
+ if (needsReference()) {
1504
+ refChoice = refChoice || refConfig.fields[0].key;
1505
+ drawRefs();
1506
+ say('');
1507
+ return;
1508
+ }
1509
+
1510
+ // They chose a field and typed something that is not that shape. Advisory
1511
+ // on the server — a value that fails is still what they believe identifies
1512
+ // them, and it is kept — but worth one chance to fix a typo here, where the
1513
+ // number is still on screen.
1514
+ if (refChoice && refValue.trim()) {
1515
+ const field = refConfig.fields.find((f) => f.key === refChoice);
1516
+ if (!refWarned && field && !validateReference(field, refValue)) {
1517
+ refError = `That does not look like a ${field.label.toLowerCase()}. Press Send again to use it anyway.`;
1518
+ // Once, and then it is their call. Our idea of the shape of somebody's
1519
+ // account number is not better than theirs, and refusing outright is
1520
+ // how a genuine reference gets lost — so the second press sends it and
1521
+ // the API keeps it, marked as not matching the expected format.
1522
+ refWarned = true;
1523
+ drawRefs();
1524
+ return;
1525
+ }
1526
+ }
1527
+
1290
1528
  sending = true;
1291
1529
  say('');
1292
1530
  refreshSendState();
@@ -1298,10 +1536,16 @@ function openPanel({ root, host, dock, client, onClose }) {
1298
1536
  title,
1299
1537
  description: $('#bc-desc').value,
1300
1538
  priority,
1539
+ references: answeredReference(),
1301
1540
  attachments: items
1302
1541
  .filter((it) => it.status === 'ready')
1303
1542
  .map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
1304
1543
  });
1544
+ if (res?.cancelled) { sending = false; sendBtn.innerHTML = original; refreshSendState(); return; }
1545
+ draftSubmitted = true;
1546
+ clearTimeout(draftTimer);
1547
+ await draftWrites;
1548
+ if (cfg.drafts !== false) await draftOperation(savedDraftKey, null).catch(() => {});
1305
1549
  succeed(res);
1306
1550
  } catch (err) {
1307
1551
  sending = false;
@@ -1439,25 +1683,22 @@ function openPanel({ root, host, dock, client, onClose }) {
1439
1683
  loadKnownIssues();
1440
1684
  titleInput.addEventListener('input', () => matchKnown(titleInput.value));
1441
1685
 
1442
- /* ---------- talk to us ---------- */
1686
+ /* ---------- talk to the team ---------- */
1443
1687
 
1444
1688
  // Asked once, when the dialog opens, and the button stays hidden unless the
1445
1689
  // answer is yes. Doing it here rather than at init keeps one request off the
1446
1690
  // page load of every site this SDK is on, for a button most of them will
1447
1691
  // never show.
1448
- // Which kind, not merely whether. A deployment offers the triage agent or a
1449
- // person, never both, and the two are wholly different promises: one is a
1450
- // machine that answers instantly, the other rings a room and may go
1451
- // unanswered. The button says which, because a reporter told "talk to us" who
1452
- // gets a robot — or who waits through twenty seconds of ringing expecting an
1453
- // instant answer — has been misled by this widget rather than by the product.
1454
- let callKind = null;
1692
+ //
1693
+ // The wording is fixed because there is one kind of call: it rings a room and
1694
+ // may go unanswered. A button that said "talk to us" would have somebody
1695
+ // expecting an instant answer sit through twenty seconds of ringing.
1696
+ let callOffered = false;
1455
1697
  function offerCall() {
1456
1698
  client.callKind()
1457
1699
  .then((kind) => {
1458
1700
  if (!kind) return;
1459
- callKind = kind;
1460
- callBtn.textContent = kind === 'live' ? 'Talk to the team' : 'Talk to us';
1701
+ callOffered = true;
1461
1702
  // Never over a panel that is still asking who this is: the call would
1462
1703
  // file a report, and on a project that requires sign-in that is the one
1463
1704
  // thing this panel is holding back.
@@ -1498,7 +1739,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1498
1739
  // The call offer is the same errand as the form, so it goes with it — and
1499
1740
  // comes back only if the API had already said this project can take one.
1500
1741
  if (shut) callBtn.hidden = true;
1501
- else if (callKind) callBtn.hidden = false;
1742
+ else if (callOffered) callBtn.hidden = false;
1502
1743
  }
1503
1744
 
1504
1745
  function drawWhoami() {
@@ -1681,23 +1922,22 @@ function openPanel({ root, host, dock, client, onClose }) {
1681
1922
  say('');
1682
1923
  refreshSendState();
1683
1924
  callBtn.disabled = true;
1684
- callBtn.innerHTML = `<span class="spinner"></span>${
1685
- callKind === 'live' ? 'Ringing…' : 'Connecting…'}`;
1925
+ callBtn.innerHTML = '<span class="spinner"></span>Ringing…';
1686
1926
 
1687
1927
  try {
1688
- // No id: this files the report from what the page was doing and calls
1689
- // about it, in one round trip.
1928
+ // No id: this files the report from what the page was doing and rings the
1929
+ // team about it, in one round trip.
1690
1930
  const handle = await client.startCall();
1691
1931
  showCall(handle);
1692
1932
  } catch (err) {
1693
1933
  sending = false;
1694
1934
  callBtn.disabled = false;
1695
- callBtn.textContent = callKind === 'live' ? 'Talk to the team' : 'Talk to us';
1935
+ callBtn.textContent = 'Talk to the team';
1696
1936
  refreshSendState();
1697
1937
  // A report filed with no call is still a report. Saying "the call
1698
1938
  // failed" and nothing else would leave the reporter thinking their
1699
1939
  // context went nowhere, and pressing Send again would file it twice.
1700
- if (err.filed) succeed(err.filed);
1940
+ if (err.filed) succeed(err.filed, { callFailed: err.reason || 'The call could not be started.' });
1701
1941
  else {
1702
1942
  say(err.message.replace('[BugCatch] ', ''));
1703
1943
  // The same follow-up the Send button does, because a call can fail for
@@ -1710,7 +1950,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1710
1950
  });
1711
1951
 
1712
1952
  /**
1713
- * The call, once it is connected.
1953
+ * The call, once the team is being rung.
1714
1954
  *
1715
1955
  * Replaces the panel for the same reason the history view does: it is the
1716
1956
  * same dialog answering a different question, and the reporter got here from
@@ -1720,51 +1960,35 @@ function openPanel({ root, host, dock, client, onClose }) {
1720
1960
  function showCall(handle) {
1721
1961
  const panel = scrim.querySelector('.panel');
1722
1962
  const ref = handle?.bugId ? String(handle.bugId).slice(0, 8) : null;
1723
- // Read off the handle rather than off `callKind`: the handle is what actually
1724
- // happened, and it is the only one of the two that cannot be stale.
1725
- const live = Boolean(handle?.live);
1726
1963
 
1727
1964
  panel.innerHTML = `
1728
1965
  <div class="call">
1729
1966
  <div class="call-head">
1730
1967
  <span class="call-dot" aria-hidden="true"></span>
1731
- <strong class="call-state">${live ? 'Ringing…' : 'Connecting…'}</strong>
1968
+ <strong class="call-state">Ringing…</strong>
1732
1969
  </div>
1733
1970
  <p class="call-note">
1734
- ${live
1735
- ? `We're ringing the team now. Someone will pick up if they're free — your report${
1736
- ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.`
1737
- : `You're talking to our automated assistant, not a person. What you say is
1738
- added to your report${ref ? ` <code>${escapeHtml(ref)}</code>` : ''} for the team to read.`}
1971
+ We're ringing the team now. Someone will pick up if they're free — your report${
1972
+ ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.
1739
1973
  </p>
1740
- <div class="call-lines" role="log" aria-live="polite"></div>
1741
1974
  <div class="foot">
1742
1975
  <span class="spacer"></span>
1743
- <button class="primary" type="button" data-act="hangup">${live ? 'Cancel' : 'Hang up'}</button>
1976
+ <button class="primary" type="button" data-act="hangup">Cancel</button>
1744
1977
  </div>
1745
1978
  </div>`;
1746
1979
 
1747
1980
  const stateEl = panel.querySelector('.call-state');
1748
- const linesEl = panel.querySelector('.call-lines');
1749
1981
  const dot = panel.querySelector('.call-dot');
1750
1982
 
1751
- // Two vocabularies, because the two calls are two different experiences. An
1752
- // agent call is connected or it failed; a live one spends its first twenty
1753
- // seconds in a state the agent call has no equivalent for — ringing — and can
1754
- // end by nobody picking up, which is not a failure and must not read as one.
1755
- const WORDING = live ? {
1983
+ // A call spends its first twenty seconds ringing, and can end by nobody
1984
+ // picking up — which is not a failure and must not read as one.
1985
+ const WORDING = {
1756
1986
  ringing: 'Ringing the team…',
1757
1987
  connecting: 'Someone answered — connecting…',
1758
1988
  connected: 'Connected',
1759
1989
  missed: 'Nobody was free',
1760
1990
  ended: 'Call ended',
1761
1991
  failed: 'The call dropped',
1762
- } : {
1763
- connecting: 'Connecting…',
1764
- listening: 'Listening',
1765
- speaking: 'Assistant is speaking',
1766
- ended: 'Call ended',
1767
- failed: 'The call dropped',
1768
1992
  };
1769
1993
 
1770
1994
  // `missed` is over too, and is the ordinary way a live call ends when the
@@ -1791,38 +2015,8 @@ function openPanel({ root, host, dock, client, onClose }) {
1791
2015
  }
1792
2016
  });
1793
2017
 
1794
- // One element per turn, replaced while a turn is still being recognised —
1795
- // appending every partial would scroll the reporter's own half-sentence
1796
- // past them as they said it.
1797
- let pending = null;
1798
- const offLines = client.on('call-transcript', ({ role, text, final }) => {
1799
- if (!text && !final) return;
1800
- if (role === 'agent') {
1801
- // The agent arrives as deltas; they belong on one line.
1802
- if (!pending || pending.dataset.role !== 'agent') {
1803
- pending = document.createElement('div');
1804
- pending.className = 'call-line agent';
1805
- pending.dataset.role = 'agent';
1806
- linesEl.appendChild(pending);
1807
- }
1808
- if (text) pending.textContent += text;
1809
- if (final) pending = null;
1810
- } else {
1811
- if (!pending || pending.dataset.role !== 'reporter') {
1812
- pending = document.createElement('div');
1813
- pending.className = 'call-line you';
1814
- pending.dataset.role = 'reporter';
1815
- linesEl.appendChild(pending);
1816
- }
1817
- pending.textContent = text;
1818
- if (final) pending = null;
1819
- }
1820
- linesEl.scrollTop = linesEl.scrollHeight;
1821
- });
1822
-
1823
2018
  panel.querySelector('[data-act="hangup"]').addEventListener('click', () => {
1824
2019
  offState();
1825
- offLines();
1826
2020
  client.endCall();
1827
2021
  close();
1828
2022
  });
@@ -1847,17 +2041,24 @@ function openPanel({ root, host, dock, client, onClose }) {
1847
2041
  * for one line and is not enough for two, and a panel that closes over the
1848
2042
  * sentence somebody started reading is the reason they open it again.
1849
2043
  */
1850
- function succeed(res) {
2044
+ function succeed(res, { callFailed = null } = {}) {
1851
2045
  const ref = res?.id ? String(res.id).slice(0, 8) : null;
1852
2046
  const eta = res?.estimate?.label ? String(res.estimate.label) : null;
2047
+ // A press of Call that filed the report and then could not ring anybody.
2048
+ // Shown as a plain "Thanks" it read as a call that had quietly worked, and
2049
+ // the reporter sat waiting for a phone that was never going to ring.
2050
+ const failed = callFailed
2051
+ ? `<p class="eta">${escapeHtml(callFailed)} Your report is filed, and the team will reply in the chat.</p>`
2052
+ : '';
1853
2053
  scrim.querySelector('.panel').innerHTML = `
1854
2054
  <div class="done">
1855
2055
  <div class="tick">${ICONS.check}</div>
1856
2056
  <h3>Thanks — that's on its way.</h3>
1857
2057
  <p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
2058
+ ${failed}
1858
2059
  ${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
1859
2060
  </div>`;
1860
- setTimeout(close, eta ? 4000 : 2000);
2061
+ setTimeout(close, failed ? 6000 : eta ? 4000 : 2000);
1861
2062
  }
1862
2063
 
1863
2064
  /**
@@ -1980,6 +2181,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1980
2181
 
1981
2182
  const thread = body.querySelector('.thread');
1982
2183
  scrollToLatest(thread);
2184
+ mountVerification(body, report);
1983
2185
  if (report.canReply) mountComposer(body, thread, bugId);
1984
2186
  }
1985
2187
 
@@ -1990,6 +2192,22 @@ function openPanel({ root, host, dock, client, onClose }) {
1990
2192
  * would also throw away a draft, a staged file and the scroll position — for
1991
2193
  * someone mid-sentence, a reply arriving would look like the widget crashed.
1992
2194
  */
2195
+ function mountVerification(body, report) {
2196
+ body.querySelector('.fix-verification')?.remove();
2197
+ if (!report.resolvedAt || !['RESOLVED', 'CLOSED'].includes(report.status)) return;
2198
+ const box = document.createElement('div'); box.className = 'fix-verification';
2199
+ if (report.verification) { box.textContent = report.verification === 'CONFIRMED' ? 'You confirmed that this fix works.' : 'Your feedback was recorded.'; }
2200
+ else {
2201
+ box.innerHTML = '<p>Does the fix work for you?</p><textarea maxlength="4000" aria-label="Fix feedback" placeholder="What happens when you try again? (optional)"></textarea><button type="button" data-result="CONFIRMED">It works now</button> <button type="button" data-result="STILL_HAPPENING">Still happening</button><p class="feedback-status" role="status"></p>';
2202
+ box.querySelectorAll('[data-result]').forEach((btn) => { btn.onclick = async () => {
2203
+ box.querySelectorAll('button').forEach((b) => { b.disabled = true; });
2204
+ try { await client.verifyFix(report.id, { result: btn.dataset.result, resolvedAt: report.resolvedAt, note: box.querySelector('textarea').value }); await showReport(report.id); }
2205
+ catch (err) { box.querySelector('.feedback-status').textContent = err.message; box.querySelectorAll('button').forEach((b) => { b.disabled = false; }); }
2206
+ }; });
2207
+ }
2208
+ body.querySelector('.report-head')?.after(box);
2209
+ }
2210
+
1993
2211
  async function refreshThread(bugId) {
1994
2212
  const body = scrim.querySelector('.history.chatting');
1995
2213
  const thread = body?.querySelector('.thread');
@@ -2001,6 +2219,7 @@ function openPanel({ root, host, dock, client, onClose }) {
2001
2219
  } catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
2002
2220
  if (!report || openReportId !== bugId) return;
2003
2221
 
2222
+ mountVerification(body, report);
2004
2223
  const pinned = atLatest(thread);
2005
2224
  thread.innerHTML = threadHtml(report.timeline);
2006
2225
  // Only follow the conversation down if they were already at the bottom.
@@ -2227,12 +2446,11 @@ function openPanel({ root, host, dock, client, onClose }) {
2227
2446
  /**
2228
2447
  * The call, on top of the report it is about.
2229
2448
  *
2230
- * A bar rather than a screen of its own, which is the opposite of what the
2231
- * agent call does — and for a reason that only applies to this kind. An agent
2232
- * call has a transcript arriving line by line, which is worth a whole panel;
2233
- * this one has nothing to show, because nobody is listening to it but the
2234
- * person on the other end. What is worth looking at during a live call is the
2235
- * report, which is what both people are talking about.
2449
+ * A bar rather than a screen of its own, unlike the one the composer's button
2450
+ * opens — and for a reason that only applies here. A call has nothing to show
2451
+ * while it runs, because nobody is listening to it but the person on the
2452
+ * other end, so what is worth looking at is the report both people are
2453
+ * talking about.
2236
2454
  */
2237
2455
  function showLiveCall(body, thread, handle) {
2238
2456
  const bar = document.createElement('div');
@@ -2267,10 +2485,7 @@ function openPanel({ root, host, dock, client, onClose }) {
2267
2485
  hangBtn.classList.remove('danger');
2268
2486
  };
2269
2487
 
2270
- const off = client.on('call', ({ state, live }) => {
2271
- // The agent call emits on the same channel. Its own panel is listening.
2272
- if (!live) return;
2273
-
2488
+ const off = client.on('call', ({ state }) => {
2274
2489
  if (state === 'ringing') who.textContent = 'Calling the team…';
2275
2490
 
2276
2491
  if (state === 'connecting') {