@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/README.md +24 -2
- package/dist/bugcatch.es.js +1987 -1675
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +176 -112
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-issues.js +30 -0
- package/src/identity.js +1 -1
- package/src/index.js +115 -10
- package/src/references.js +155 -3
- package/src/updates.js +36 -6
- package/src/widget.js +297 -49
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 {
|
|
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
|
|
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
|
|
1770
|
-
function
|
|
1771
|
-
if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
|
|
1772
|
-
return
|
|
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 (!
|
|
1783
|
-
const field = refConfig.fields.find((f) => f.key === refChoice) ||
|
|
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 =
|
|
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>${
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
${
|
|
1812
|
-
|
|
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
|
|
1816
|
-
inputmode="${escapeHtml(field
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
*
|
|
1870
|
-
*
|
|
1871
|
-
*
|
|
1872
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1898
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
3171
|
-
//
|
|
3172
|
-
//
|
|
3173
|
-
|
|
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}
|
|
3176
|
-
<button type="button" data-quick="No, that is not the problem">
|
|
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}">
|