@sphoro/bugcatch-web-sdk 0.15.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/src/widget.js CHANGED
@@ -14,7 +14,8 @@ import {
14
14
  import { draftKey, draftOperation, readDraft } from './drafts.js';
15
15
  import { readSession } from './session.js';
16
16
  import { sendMessage } from './updates.js';
17
- import { matchReferences, validateReference } from './references.js';
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
@@ -503,6 +529,14 @@ textarea { min-height: 88px; resize: vertical; }
503
529
  .refs .err { color: var(--bc-danger); font-weight: 550; }
504
530
  .refs .link { margin-top: 10px; font-size: 12px; color: var(--bc-muted); }
505
531
  .refs .link:hover { color: var(--bc-fg); }
532
+ .refs .chips.cands button { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 550; }
533
+ /* "We read your ID" — under the description, while they type. */
534
+ .ref-found {
535
+ display: flex; align-items: center; gap: 6px;
536
+ margin: -6px 0 14px; font-size: 12px; line-height: 1.45; color: var(--bc-muted);
537
+ }
538
+ .ref-found svg { width: 14px; height: 14px; flex: none; color: var(--bc-ok); }
539
+ .ref-found b { font-weight: 600; color: var(--bc-fg); }
506
540
 
507
541
  /* ---------- severity ---------- */
508
542
  .seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md); }
@@ -856,6 +890,10 @@ button.quiet:hover { color: var(--bc-accent); }
856
890
  background: var(--bc-accent-soft); border-color: var(--bc-accent-line);
857
891
  }
858
892
  .assistant.resolved { background: color-mix(in srgb, var(--bc-ok) 9%, var(--bc-bg)); border-color: color-mix(in srgb, var(--bc-ok) 35%, var(--bc-border)); }
893
+ /* On a call: the call's own card is what they are watching. This one states
894
+ what is happening and stops moving — no spinner, no travelling bar. */
895
+ .assistant.calling { background: var(--bc-bg); }
896
+ .assistant.calling .as-progress i { transition: none; }
859
897
  .as-body.entering { animation: bc-rise 360ms var(--bc-ease) both; }
860
898
  .as-top { display: flex; gap: 11px; align-items: center; }
861
899
  .as-orb {
@@ -1049,6 +1087,7 @@ button.quiet:hover { color: var(--bc-accent); }
1049
1087
  animation: bc-rise 240ms var(--bc-ease) both;
1050
1088
  }
1051
1089
  .livecall .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1090
+ .livecall .call-kept { font-size: 11px; opacity: .75; }
1052
1091
  .livecall .secs { font-variant-numeric: tabular-nums; color: var(--bc-muted); }
1053
1092
  .livecall .act {
1054
1093
  padding: 5px 11px; border-radius: 999px; border: 1px solid var(--bc-border);
@@ -1328,6 +1367,9 @@ function openPanel({ root, host, dock, client, onClose }) {
1328
1367
  <div class="body">
1329
1368
  <div class="slot" role="alert"></div>
1330
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>
1331
1373
  <div class="known" hidden></div>
1332
1374
  <div class="compose">
1333
1375
  <div class="field">
@@ -1400,6 +1442,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1400
1442
  const whoami = $('.whoami');
1401
1443
  const refsBox = $('.refs');
1402
1444
  const known = $('.known');
1445
+ const picks = $('.picks');
1403
1446
  const historyBtn = $('[data-act="history"]');
1404
1447
 
1405
1448
  const draftStatus = document.createElement('div');
@@ -1750,9 +1793,16 @@ function openPanel({ root, host, dock, client, onClose }) {
1750
1793
  // answer, is a reasonable thing to be asked. Most reporters never see it: the
1751
1794
  // loan number was in the sentence.
1752
1795
  let refConfig = { policy: 'off', fields: [] };
1753
- let refChoice = null; // which field they are answering
1796
+ let refOpen = false;
1797
+ let refChoice = null; // which field they are answering; null = not picked yet
1754
1798
  let refValue = '';
1755
1799
  let refError = '';
1800
+ // What we read in their text but could not place for sure, offered as taps:
1801
+ // [{ value, kinds: [{ key, label }] }]. `refMode` says why the card is open —
1802
+ // 'confirm' when the text had two answers, 'suggest' when it had an unlabelled
1803
+ // one, 'ask' when it had none.
1804
+ let refCandidates = [];
1805
+ let refMode = 'ask';
1756
1806
  // Set once they have answered or explicitly skipped, so Send stops asking.
1757
1807
  let refSettled = false;
1758
1808
  // Set once we have said "that does not look right" about the current value.
@@ -1763,13 +1813,18 @@ function openPanel({ root, host, dock, client, onClose }) {
1763
1813
  let refWarned = false;
1764
1814
 
1765
1815
  client.referenceFields()
1766
- .then((cfg) => { refConfig = cfg; })
1816
+ .then((cfg) => { refConfig = cfg; showFound(); })
1767
1817
  .catch(() => { /* off is the safe answer to every failure */ });
1768
1818
 
1769
- /** What the report already carries, read out of what they typed. */
1770
- function referencesInText() {
1771
- if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
1772
- return matchReferences(refConfig.fields, [titleInput.value, descInput.value]);
1819
+ /** What the text says: found (sure), conflicts (ask which), loose (offer). */
1820
+ function readText() {
1821
+ if (refConfig.policy === 'off' || !refConfig.fields.length) return { found: [], conflicts: [], loose: [] };
1822
+ return readReferences(refConfig.fields, [titleInput.value, descInput.value]);
1823
+ }
1824
+
1825
+ /** Sure enough to send without a question: something found, nothing contested. */
1826
+ function textAnswers(read = readText()) {
1827
+ return read.found.length > 0 && read.conflicts.length === 0;
1773
1828
  }
1774
1829
 
1775
1830
  /** The answer to send, or an empty list. */
@@ -1778,50 +1833,127 @@ function openPanel({ root, host, dock, client, onClose }) {
1778
1833
  return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
1779
1834
  }
1780
1835
 
1836
+ // A quiet line under the description while they type, so somebody who put
1837
+ // their loan number in the sentence can see it was read and does not go
1838
+ // looking for a box to type it into again.
1839
+ const foundNote = document.createElement('p');
1840
+ foundNote.className = 'ref-found';
1841
+ foundNote.setAttribute('aria-live', 'polite');
1842
+ foundNote.hidden = true;
1843
+ descInput.closest('.field').after(foundNote);
1844
+ let foundTimer;
1845
+ function showFound() {
1846
+ const read = readText();
1847
+ if (textAnswers(read)) {
1848
+ const list = read.found.slice(0, 2).map((r) => `${escapeHtml(r.label)} <b>${escapeHtml(shortId(r.value))}</b>`).join(', ');
1849
+ foundNote.innerHTML = `${ICONS.check}<span>Found your ${list} — no need to add it again.</span>`;
1850
+ foundNote.hidden = false;
1851
+ // They have answered in the text; a card still asking would be not listening.
1852
+ if (refOpen && !answeredReference().length) { refOpen = false; drawRefs(); }
1853
+ } else {
1854
+ foundNote.hidden = true;
1855
+ foundNote.innerHTML = '';
1856
+ }
1857
+ }
1858
+ const queueFound = () => { clearTimeout(foundTimer); foundTimer = setTimeout(showFound, 250); };
1859
+ titleInput.addEventListener('input', queueFound);
1860
+ descInput.addEventListener('input', queueFound);
1861
+
1862
+ /** A UUID is long; the chip shows its ends, the title shows all of it. */
1863
+ function shortId(value) {
1864
+ return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
1865
+ }
1866
+
1867
+ function candidateFor(value) {
1868
+ const v = value.trim().toLowerCase();
1869
+ return refCandidates.find((c) => c.value.toLowerCase() === v) || null;
1870
+ }
1871
+
1872
+ /** Pick a candidate: its value, and its kind when only one kind fits. */
1873
+ function takeCandidate(c) {
1874
+ refValue = c.value;
1875
+ if (c.kinds.length === 1) refChoice = c.kinds[0].key;
1876
+ else if (!c.kinds.some((k) => k.key === refChoice)) refChoice = null;
1877
+ refError = '';
1878
+ refWarned = false;
1879
+ }
1880
+
1781
1881
  function drawRefs() {
1782
- if (!refChoice) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
1783
- const field = refConfig.fields.find((f) => f.key === refChoice) || refConfig.fields[0];
1882
+ if (!refOpen) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
1883
+ const field = refConfig.fields.find((f) => f.key === refChoice) || null;
1784
1884
  const optional = refConfig.policy !== 'required';
1885
+ const current = candidateFor(refValue);
1886
+ // Kinds worth offering: the ones the picked value could be, when we know;
1887
+ // every kind otherwise. Never zero — a guess of ours must not lock out the
1888
+ // right answer.
1889
+ const kinds = current && current.kinds.length > 1
1890
+ ? refConfig.fields.filter((f) => current.kinds.some((k) => k.key === f.key))
1891
+ : refConfig.fields;
1892
+ // Two loan numbers: the kind is not in question, only which one — so the
1893
+ // kind chips would be a second question nobody needs to answer.
1894
+ const sharedKind = refCandidates.length > 1
1895
+ && refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === refCandidates[0].kinds[0].key);
1896
+
1897
+ const head = {
1898
+ confirm: refCandidates.length > 1
1899
+ ? ['Which one is this about?', 'We found more than one ID in your message. Pick the one this problem is about.']
1900
+ : ['Is this the right ID?', 'This could be more than one kind of ID. Tell us which it is.'],
1901
+ suggest: ['Is this your ID?', 'We spotted this in your message. Tell us what it is so we can find your record.'],
1902
+ ask: optional
1903
+ ? ['One more thing', 'So we can find your record — this helps us answer faster.']
1904
+ : ['We need one detail', 'We need one of these to look this up.'],
1905
+ }[refMode];
1785
1906
 
1786
1907
  refsBox.hidden = false;
1787
1908
  // A handful of kinds read best as chips to tap; a long list goes back to
1788
1909
  // a select so the card does not turn into a wall of pills.
1789
- const many = refConfig.fields.length > 6;
1910
+ const many = kinds.length > 6;
1790
1911
  refsBox.innerHTML = `
1791
1912
  <div class="ask-head">
1792
1913
  <span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>
1793
1914
  <div>
1794
- <strong>${optional ? 'One more thing' : 'We need one detail'}</strong>
1795
- <p class="why">${escapeHtml(
1796
- optional
1797
- ? 'So we can find your record — this helps us answer faster.'
1798
- : 'We need one of these to look this up.',
1799
- )}</p>
1915
+ <strong>${escapeHtml(head[0])}</strong>
1916
+ <p class="why">${escapeHtml(head[1])}</p>
1800
1917
  </div>
1801
1918
  </div>
1802
- ${refConfig.fields.length > 1 && !many ? `
1919
+ ${refCandidates.length > 1 ? `
1920
+ <div class="chips cands" role="group" aria-label="IDs found in your message">
1921
+ ${refCandidates.map((c, i) => `
1922
+ <button type="button" data-ref-cand="${i}" title="${escapeHtml(c.value)}"
1923
+ aria-pressed="${!!current && current === c}">${escapeHtml(shortId(c.value))}</button>
1924
+ `).join('')}
1925
+ </div>` : ''}
1926
+ ${kinds.length > 1 && !many && !sharedKind ? `
1803
1927
  <div class="chips" role="group" aria-label="Which detail you are giving">
1804
- ${refConfig.fields.map((f) => `
1805
- <button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${f.key === field.key}">${escapeHtml(f.label)}</button>
1928
+ ${kinds.map((f) => `
1929
+ <button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${!!field && f.key === field.key}">${escapeHtml(f.label)}</button>
1806
1930
  `).join('')}
1807
1931
  </div>` : ''}
1808
1932
  <div class="row">
1809
1933
  ${many ? `
1810
1934
  <select class="ref-kind" aria-label="Which detail you are giving">
1811
- ${refConfig.fields.map((f) => `
1812
- <option value="${escapeHtml(f.key)}" ${f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
1935
+ ${field ? '' : '<option value="" selected disabled>What is it?</option>'}
1936
+ ${kinds.map((f) => `
1937
+ <option value="${escapeHtml(f.key)}" ${field && f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
1813
1938
  `).join('')}
1814
1939
  </select>` : ''}
1815
- <input class="ref-value${refError ? ' invalid' : ''}" type="${field.inputMode === 'email' ? 'email' : 'text'}"
1816
- inputmode="${escapeHtml(field.inputMode === 'numeric' || field.inputMode === 'tel' ? 'numeric' : 'text')}"
1940
+ <input class="ref-value${refError ? ' invalid' : ''}" type="${field?.inputMode === 'email' ? 'email' : 'text'}"
1941
+ inputmode="${escapeHtml(field?.inputMode === 'numeric' || field?.inputMode === 'tel' ? 'numeric' : 'text')}"
1817
1942
  value="${escapeHtml(refValue)}"
1818
- placeholder="${escapeHtml(field.placeholder || field.label)}"
1819
- aria-label="${escapeHtml(field.label)}" ${refError ? 'aria-invalid="true"' : ''} />
1943
+ placeholder="${escapeHtml(field ? (field.placeholder || field.label) : 'Your ID')}"
1944
+ aria-label="${escapeHtml(field ? field.label : 'Your ID')}" ${refError ? 'aria-invalid="true"' : ''} />
1820
1945
  </div>
1821
- ${field.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
1946
+ ${field?.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
1822
1947
  ${refError ? `<p class="field-err" role="alert">${ICONS.alert}<span>${escapeHtml(refError)}</span></p>` : ''}
1823
- ${optional ? '<button type="button" class="link" data-act="ref-skip">I don\'t have this — send without it</button>' : ''}`;
1948
+ ${optional ? `<button type="button" class="link" data-act="ref-skip">${
1949
+ refMode === 'ask' ? 'I don\'t have this — send without it' : 'Not sure — send as it is'}</button>` : ''}`;
1824
1950
 
1951
+ refsBox.querySelectorAll('[data-ref-cand]').forEach((chip) => {
1952
+ chip.addEventListener('click', () => {
1953
+ takeCandidate(refCandidates[Number(chip.dataset.refCand)]);
1954
+ drawRefs();
1955
+ });
1956
+ });
1825
1957
  refsBox.querySelectorAll('[data-ref-kind]').forEach((chip) => {
1826
1958
  chip.addEventListener('click', () => {
1827
1959
  refChoice = chip.dataset.refKind;
@@ -1851,7 +1983,9 @@ function openPanel({ root, host, dock, client, onClose }) {
1851
1983
  refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
1852
1984
  // Skipping is answering. Without this the panel asks again on the next
1853
1985
  // press and the reporter is in a loop with a button that does nothing.
1986
+ // The text still goes; the server reads it either way.
1854
1987
  refSettled = true;
1988
+ refOpen = false;
1855
1989
  refChoice = null;
1856
1990
  refValue = '';
1857
1991
  refError = '';
@@ -1860,23 +1994,54 @@ function openPanel({ root, host, dock, client, onClose }) {
1860
1994
  say('');
1861
1995
  });
1862
1996
 
1863
- value.focus();
1997
+ // Focus where the next decision is: the kind, when that is all that is
1998
+ // missing; the box otherwise.
1999
+ const next = !field && refValue ? refsBox.querySelector('[data-ref-kind], .ref-kind') : null;
2000
+ (next || value).focus();
2001
+ }
2002
+
2003
+ /**
2004
+ * Open the question, pre-filled with the best of what the text offered.
2005
+ *
2006
+ * Conflicts first — the text said two things and only the reporter knows
2007
+ * which — then loose IDs, which are probably the answer and only need a
2008
+ * name. Nothing, and it is the plain question it always was.
2009
+ */
2010
+ function openRefs(read) {
2011
+ refCandidates = read.conflicts.length ? read.conflicts : read.loose.slice(0, 4);
2012
+ refMode = read.conflicts.length ? 'confirm' : read.loose.length ? 'suggest' : 'ask';
2013
+ refOpen = true;
2014
+ if (refCandidates.length) {
2015
+ // One contested value: pre-fill it so they only choose what it is. Two
2016
+ // or more: pick nothing, because picking is the question.
2017
+ if (refCandidates.length === 1) takeCandidate(refCandidates[0]);
2018
+ else {
2019
+ refValue = '';
2020
+ const [first] = refCandidates;
2021
+ const same = refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === first.kinds[0].key);
2022
+ refChoice = same ? first.kinds[0].key : null;
2023
+ }
2024
+ } else {
2025
+ refChoice = refChoice || refConfig.fields[0].key;
2026
+ }
2027
+ drawRefs();
1864
2028
  }
1865
2029
 
1866
2030
  /**
1867
2031
  * Whether Send should stop and ask.
1868
2032
  *
1869
- * True at most once per report. The question is skippable on an `optional`
1870
- * project and not on a `required` one, and on both the answer to "have we
1871
- * already got one" is read from the text every time — somebody who types the
1872
- * loan number into the description after being asked has answered the
1873
- * question, and being asked again would be the widget not listening.
2033
+ * Never when the text answered it outright. Once when the text said two
2034
+ * things or something unlabelled — on every policy, because sending the
2035
+ * wrong record's id with confidence is worse than one extra tap. And the
2036
+ * plain question on `optional`/`required` projects as before.
1874
2037
  */
1875
2038
  function needsReference() {
1876
2039
  if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
1877
- if (referencesInText().length) return false;
1878
2040
  if (answeredReference().length) return false;
1879
- return !refSettled || refConfig.policy === 'required';
2041
+ const read = readText();
2042
+ if (textAnswers(read)) return false;
2043
+ if (refSettled && refConfig.policy !== 'required') return false;
2044
+ return read;
1880
2045
  }
1881
2046
 
1882
2047
  /* ---------- send ---------- */
@@ -1894,9 +2059,18 @@ function openPanel({ root, host, dock, client, onClose }) {
1894
2059
  // costs one extra press on projects that want an identifier, and it is the
1895
2060
  // only shape that does not put paperwork in front of a person who came here
1896
2061
  // to say something is broken.
1897
- if (needsReference()) {
1898
- refChoice = refChoice || refConfig.fields[0].key;
2062
+ //
2063
+ // First, though: a value in the box but no kind picked. Asked rather than
2064
+ // guessed — a wrong kind joins on the wrong table.
2065
+ if (refOpen && refValue.trim() && !refChoice) {
2066
+ refError = 'Pick what this ID is.';
1899
2067
  drawRefs();
2068
+ return;
2069
+ }
2070
+
2071
+ const read = needsReference();
2072
+ if (read) {
2073
+ if (!refOpen) openRefs(read); else drawRefs();
1900
2074
  say('');
1901
2075
  return;
1902
2076
  }
@@ -1931,6 +2105,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1931
2105
  description: descInput.value,
1932
2106
  priority,
1933
2107
  references: answeredReference(),
2108
+ issue: pickedIssue?.key || null,
1934
2109
  attachments: items
1935
2110
  .filter((it) => it.status === 'ready')
1936
2111
  .map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
@@ -2077,6 +2252,57 @@ function openPanel({ root, host, dock, client, onClose }) {
2077
2252
  loadKnownIssues();
2078
2253
  titleInput.addEventListener('input', () => matchKnown(titleInput.value));
2079
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
+
2080
2306
  /* ---------- talk to the team ---------- */
2081
2307
 
2082
2308
  // Asked once, when the dialog opens, and the button stays hidden unless the
@@ -2639,7 +2865,8 @@ function openPanel({ root, host, dock, client, onClose }) {
2639
2865
  <div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
2640
2866
  <div class="as-top">
2641
2867
  <span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
2642
- : a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
2868
+ : a.phase === 'calling' ? ICONS.phone
2869
+ : a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
2643
2870
  <div class="as-text">
2644
2871
  <div class="as-title">${escapeHtml(a.title)}</div>
2645
2872
  <div class="as-detail">${escapeHtml(a.detail)}</div>
@@ -2664,6 +2891,9 @@ function openPanel({ root, host, dock, client, onClose }) {
2664
2891
  }
2665
2892
 
2666
2893
  // While it works, a "thinking" bubble sits where its next message will land.
2894
+ // Never during a call: somebody is on the phone about this report, nothing
2895
+ // is being analysed (the analysis waits for the transcript), and a bubble
2896
+ // typing away underneath a live call is the assistant talking over them.
2667
2897
  if (thread) {
2668
2898
  const pinned = atLatest(thread);
2669
2899
  const typing = thread.querySelector('.typing-row');
@@ -2677,6 +2907,10 @@ function openPanel({ root, host, dock, client, onClose }) {
2677
2907
 
2678
2908
  if (a.phase === 'analyzing') {
2679
2909
  assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
2910
+ } else if (a.phase === 'calling') {
2911
+ // Slower: the call's own card is what the reporter is watching, and this
2912
+ // only has to notice when the call ends and the analysis begins.
2913
+ assistantTimer = setTimeout(() => refreshThread(bugId), 10000);
2680
2914
  } else if (a.phase === 'needs_info' && previous !== 'needs_info') {
2681
2915
  body.querySelector('.composer textarea')?.focus();
2682
2916
  }
@@ -2753,8 +2987,9 @@ function openPanel({ root, host, dock, client, onClose }) {
2753
2987
  <div class="staged" hidden></div>
2754
2988
  <div class="row">
2755
2989
  <div class="tools">
2990
+ ${cfg.attachments === false ? '' : `
2756
2991
  <button class="tool" type="button" data-act="attach" title="Attach a file" aria-label="Attach a file">${ICONS.paperclip}</button>
2757
- <button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>
2992
+ <button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>`}
2758
2993
  <button class="tool" type="button" data-act="livecall" title="Call the team" aria-label="Call the team" hidden>${ICONS.phone}</button>
2759
2994
  </div>
2760
2995
  <textarea rows="1" placeholder="Write a reply…" aria-label="Write a reply"></textarea>
@@ -2834,7 +3069,11 @@ function openPanel({ root, host, dock, client, onClose }) {
2834
3069
  }
2835
3070
  });
2836
3071
 
2837
- composer.querySelector('[data-act="attach"]').addEventListener('click', () => picker.click());
3072
+ // Absent when the host set `attachments: false` — which the report form
3073
+ // has always honoured and this composer did not, so a host keeping files
3074
+ // and microphone audio outside its compliance perimeter still had both
3075
+ // offered in the follow-up thread.
3076
+ composer.querySelector('[data-act="attach"]')?.addEventListener('click', () => picker.click());
2838
3077
  picker.addEventListener('change', () => {
2839
3078
  say('');
2840
3079
  for (const file of picker.files) {
@@ -2847,12 +3086,12 @@ function openPanel({ root, host, dock, client, onClose }) {
2847
3086
  picker.value = '';
2848
3087
  });
2849
3088
 
2850
- if (!voiceSupported()) {
3089
+ if (voiceBtn && !voiceSupported()) {
2851
3090
  voiceBtn.disabled = true;
2852
3091
  voiceBtn.title = 'This browser cannot record audio';
2853
3092
  }
2854
3093
 
2855
- voiceBtn.addEventListener('click', async () => {
3094
+ voiceBtn?.addEventListener('click', async () => {
2856
3095
  if (recorder) {
2857
3096
  const handle = recorder;
2858
3097
  recorder = null;
@@ -2971,6 +3210,7 @@ function openPanel({ root, host, dock, client, onClose }) {
2971
3210
  <span class="call-dot" aria-hidden="true"></span>
2972
3211
  <span class="who" role="status" aria-live="polite">Calling the team…</span>
2973
3212
  <span class="secs" hidden></span>
3213
+ ${handle?.recorded ? '<span class="call-kept">This call is recorded.</span>' : ''}
2974
3214
  <button class="act" type="button" data-act="mute" hidden>Mute</button>
2975
3215
  <button class="act danger" type="button" data-act="hangup">Cancel</button>`;
2976
3216
  body.insertBefore(bar, thread);
@@ -3167,15 +3407,23 @@ function messageHtml(message, state = '') {
3167
3407
  }
3168
3408
  if (state.includes('cont')) who = '';
3169
3409
 
3170
- // "Is this the problem you are facing?" — the confirm question the
3171
- // investigation asks (bugcatch-workers check-loop). One tap answers it; the
3172
- // buttons only show on the latest message, so an answered question has none.
3173
- const confirm = automated && /reply "yes" or "no"/i.test(body)
3410
+ // The assistant's one answer ends by inviting a correction (check-rules
3411
+ // confirmMessage). One tap sends it, and what comes back goes to the person
3412
+ // who now owns the report rather than to another automated round — so the
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.
3416
+ const confirm = automated && /it goes to the same person/i.test(body)
3174
3417
  ? `<div class="quick" role="group" aria-label="Answer">
3175
- <button type="button" data-quick="Yes, this is the problem">${ICONS.check}Yes, that's it</button>
3176
- <button type="button" data-quick="No, that is not the problem">No, it's something else</button>
3418
+ <button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
3419
+ <button type="button" data-quick="No, that is not the problem">That's not it</button>
3177
3420
  </div>`
3178
- : '';
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
+ : '';
3179
3427
 
3180
3428
  return `
3181
3429
  <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">