@gotcos/glasses-server 6.98.1 → 6.99.16

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,32 @@
1
+ /* COS Control Web: session questions and approvals (panes/questions.js), server 6.99.12. */
2
+ .sq-host { display: grid; gap: 12px; margin: 0 0 16px; }
3
+ .sq-host[hidden] { display: none; }
4
+ .sq-card { background: var(--panel); border: 1px solid color-mix(in srgb, var(--hot) 45%, var(--panel-border)); border-radius: 16px; padding: 14px 16px 12px; min-width: 0; }
5
+ .sq-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
6
+ .sq-title { margin: 0; font: 600 16px/1.3 var(--body); color: var(--ink); overflow-wrap: anywhere; }
7
+ .sq-secs { font: 500 12.5px/1.3 var(--body); color: var(--hot); white-space: nowrap; }
8
+ .sq-q { border: 0; margin: 12px 0 0; padding: 0; min-width: 0; }
9
+ .sq-q-text { padding: 0; font: 500 15px/1.4 var(--body); color: var(--ink); overflow-wrap: anywhere; }
10
+ .sq-q-tag { font-size: 12.5px; font-weight: 500; color: var(--tertiary); }
11
+ .sq-fine { margin: 2px 0 0; font-size: 12.5px; color: var(--tertiary); }
12
+ .sq-options { display: grid; gap: 2px; margin-top: 8px; }
13
+ .sq-option { display: flex; align-items: flex-start; gap: 10px; min-height: 44px; padding: 8px 10px; border-radius: 10px; cursor: pointer; }
14
+ .sq-option:hover { background: var(--qopen); }
15
+ .sq-option input { margin: 3px 0 0; width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
16
+ .sq-option-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
17
+ .sq-option-label { font: 600 14.5px/1.3 var(--body); color: var(--ink); overflow-wrap: anywhere; }
18
+ .sq-option-desc { font-size: 13px; line-height: 1.4; color: var(--sub); overflow-wrap: anywhere; }
19
+ .sq-other { margin-top: 6px; width: 100%; height: 42px; font-size: 16px; }
20
+ .sq-command { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: var(--qopen); border: 1px solid var(--panel-border);
21
+ font: 500 13px/1.45 var(--mono); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow: auto; }
22
+ .sq-details { margin-top: 8px; font-size: 13px; color: var(--sub); }
23
+ .sq-details summary { cursor: pointer; min-height: 32px; display: flex; align-items: center; }
24
+ .sq-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 12px; }
25
+ .sq-acts .cx-btn { height: 44px; padding: 0 18px; font-size: 15px; border-radius: 12px; }
26
+ .sq-deny { background: transparent; border: 1.5px solid var(--accent); color: var(--accent); }
27
+ .sq-leave { min-height: 44px; font-size: 14px; font-weight: 500; color: var(--ink); }
28
+ .sq-note { margin: 8px 0 0; font-size: 13px; color: var(--hot); }
29
+ .sq-note.hidden { display: none; }
30
+ @media (max-width: 480px) {
31
+ .sq-acts .cx-btn { flex: 1; justify-content: center; }
32
+ }
@@ -0,0 +1,275 @@
1
+ /* COS Control Web: session questions and approvals (server 6.99.12), the cards the Even app shows
2
+ (session-question-phone.ts), answered with the same POST /api/session-questions/:id/answer.
3
+
4
+ Shown at the top of every pane while one waits, so wherever the page is, the question is in front of you.
5
+ The page reads GET /api/session-questions?client=web only when all of these hold:
6
+ - /api/models says capabilities.sessionQuestions.webClient (the server takes the browser session on that route; an
7
+ older server answers it 401, which would sign the page out, so the page never calls it there);
8
+ - the broker is on (sessionQuestions.enabled);
9
+ - this tab is visible and signed in.
10
+ `client=web` makes the Mac count this page as a client that can answer (the same 30 s window as the phone), so it
11
+ holds a question for it instead of showing its own dialog at once. A hidden tab stops polling, and the Mac takes
12
+ its questions back within 30 s. The poll is a plain fetch: a 401 stops the poll quietly and never signs the page out
13
+ (the panes say so on their next read).
14
+
15
+ Allow once, Deny and Send are tap-then-confirm, as in the Even app: an approval runs a command on the Mac. Leave for
16
+ the Mac hands the request back to the Mac's own dialog. Plan approvals never come here: the broker never holds
17
+ ExitPlanMode (lib/permission-broker.ts UNBROKERED_TOOLS). */
18
+ (function () {
19
+ 'use strict';
20
+ var W = window.COSWeb;
21
+ var esc = W.esc, api = W.api;
22
+ var host = document.getElementById('sq-host');
23
+ if (!host) return;
24
+
25
+ var Q = {
26
+ cap: null, // capabilities.sessionQuestions, once read
27
+ capAt: 0,
28
+ items: [], // pending items, oldest first
29
+ picks: {}, // id -> [{ labels: [], other: '' }] per question
30
+ armed: {}, // id -> 'send' | 'allow' | 'deny'
31
+ sending: {}, // id -> clientAnswerId while a POST is out
32
+ notes: {}, // id -> one line under the card
33
+ answerIds: {}, // id -> the clientAnswerId this page answers under (a retry replays it)
34
+ timer: null,
35
+ tick: null,
36
+ stopped: false
37
+ };
38
+ var DEFAULT_POLL_MS = 10000;
39
+
40
+ function signedIn() { var shell = document.getElementById('app-shell'); return !!(shell && !shell.classList.contains('hidden')); }
41
+ function answerId() {
42
+ var b = new Uint8Array(12); crypto.getRandomValues(b);
43
+ return 'gw_' + Array.prototype.map.call(b, function (x) { return (x + 0x100).toString(16).slice(1); }).join('');
44
+ }
45
+ function secondsLeft(item) {
46
+ var created = Date.parse(item.createdAt), deadline = Date.parse(item.deadlineAt);
47
+ var span = Math.max(0, Math.ceil((deadline - created) / 1000));
48
+ var left = Math.ceil((deadline - Date.now()) / 1000);
49
+ return Math.max(0, Math.min(span, isFinite(left) ? left : 0));
50
+ }
51
+ function timeLeft(s) { return s > 99 ? Math.ceil(s / 60) + ' min left' : s + ' s left'; }
52
+ function engine(item) { return item.provider === 'codex' ? 'Codex' : 'Claude'; }
53
+
54
+ // ── the capability, then the poll ─────────────────────
55
+ function readCap() {
56
+ if (Q.cap && Date.now() - Q.capAt < 5 * 60000) return Promise.resolve(Q.cap);
57
+ return api('/api/models').then(function (m) {
58
+ Q.cap = (m && m.capabilities && m.capabilities.sessionQuestions) || { enabled: false };
59
+ Q.capAt = Date.now();
60
+ return Q.cap;
61
+ }, function () { return null; });
62
+ }
63
+ function usable(cap) { return !!(cap && cap.enabled === true && cap.webClient === true); }
64
+ function schedule(ms) {
65
+ clearTimeout(Q.timer);
66
+ Q.timer = setTimeout(poll, ms);
67
+ }
68
+ function poll() {
69
+ clearTimeout(Q.timer);
70
+ if (Q.stopped || document.hidden || !signedIn()) return;
71
+ readCap().then(function (cap) {
72
+ if (!usable(cap)) { clearCards(); schedule(60000); return; }
73
+ return fetch('/api/session-questions?client=web', { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store' }).then(function (res) {
74
+ if (res.status === 401) { Q.stopped = true; clearCards(); return; }
75
+ if (!res.ok) { schedule(DEFAULT_POLL_MS); return; }
76
+ return res.json().then(function (d) {
77
+ if (!d || d.enabled === false) { clearCards(); schedule(60000); return; }
78
+ take(Array.isArray(d.items) ? d.items : []);
79
+ var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
80
+ // While one waits, read faster so an answer from the glasses or the Mac clears it here too.
81
+ schedule(Q.items.length ? Math.min(every, 3000) : every);
82
+ });
83
+ }, function () { schedule(DEFAULT_POLL_MS); });
84
+ });
85
+ }
86
+ function start() { Q.stopped = false; poll(); }
87
+ function stop() { clearTimeout(Q.timer); }
88
+
89
+ // ── state from a poll ─────────────────────────────────
90
+ function take(items) {
91
+ var pending = items.filter(function (i) { return i && i.answerable !== false && (i.kind === 'question' || i.kind === 'approval'); });
92
+ var before = Q.items.map(function (i) { return i.id; }).join('|');
93
+ var after = pending.map(function (i) { return i.id; }).join('|');
94
+ var ids = {}; pending.forEach(function (i) { ids[i.id] = 1; });
95
+ Object.keys(Q.picks).forEach(function (id) { if (!ids[id]) { delete Q.picks[id]; delete Q.armed[id]; delete Q.notes[id]; delete Q.answerIds[id]; } });
96
+ pending.forEach(function (i) {
97
+ if (!Q.picks[i.id]) Q.picks[i.id] = (i.questions || []).map(function () { return { labels: [], other: '' }; });
98
+ });
99
+ Q.items = pending;
100
+ // A card that is already drawn is never redrawn by a poll: a half-typed Other answer stays as it is.
101
+ if (before !== after) render();
102
+ }
103
+ function clearCards() { if (Q.items.length) { Q.items = []; render(); } }
104
+
105
+ // ── drawing ───────────────────────────────────────────
106
+ function complete(item) {
107
+ var picks = Q.picks[item.id] || [];
108
+ return (item.questions || []).every(function (q, qi) {
109
+ var p = picks[qi] || { labels: [], other: '' };
110
+ var n = p.labels.length + (p.other.trim() ? 1 : 0);
111
+ return n > 0 && (q.multiSelect || n === 1);
112
+ });
113
+ }
114
+ function noteHtml(item) {
115
+ var text = Q.notes[item.id] || '';
116
+ return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
117
+ }
118
+ function questionCard(item) {
119
+ var picks = Q.picks[item.id] || [];
120
+ var sending = !!Q.sending[item.id];
121
+ var blocks = (item.questions || []).map(function (q, qi) {
122
+ var p = picks[qi] || { labels: [], other: '' };
123
+ var type = q.multiSelect ? 'checkbox' : 'radio';
124
+ var name = 'sq-' + item.id + '-' + qi;
125
+ return '<fieldset class="sq-q"><legend class="sq-q-text">' + esc(q.question) + (q.header ? ' <span class="sq-q-tag">' + esc(q.header) + '</span>' : '') + '</legend>' +
126
+ (q.multiSelect ? '<p class="sq-fine">Pick any that apply.</p>' : '') +
127
+ '<div class="sq-options">' + q.options.map(function (o, oi) {
128
+ var on = p.labels.indexOf(o.label) >= 0;
129
+ return '<label class="sq-option"><input type="' + type + '" name="' + esc(name) + '" data-sq-pick data-sq-id="' + esc(item.id) + '" data-q="' + qi + '" data-o="' + oi + '"' + (on ? ' checked' : '') + (sending ? ' disabled' : '') + '>' +
130
+ '<span class="sq-option-text"><span class="sq-option-label">' + esc(o.label) + '</span>' + (o.description ? '<span class="sq-option-desc">' + esc(o.description) + '</span>' : '') + '</span></label>';
131
+ }).join('') + '</div>' +
132
+ '<input class="field sq-other" type="text" maxlength="2000" placeholder="Other: type your own answer" aria-label="Your own answer" data-sq-other data-sq-id="' + esc(item.id) + '" data-q="' + qi + '" value="' + esc(p.other) + '"' + (sending ? ' disabled' : '') + '>' +
133
+ '</fieldset>';
134
+ }).join('');
135
+ var armed = Q.armed[item.id] === 'send';
136
+ return '<article class="sq-card" data-sq-card="' + esc(item.id) + '" data-kind="question">' +
137
+ '<header class="sq-head"><h2 class="sq-title">' + esc(engine(item)) + ' has a question</h2><span class="sq-secs" data-sq-secs="' + esc(item.id) + '">' + esc(timeLeft(secondsLeft(item))) + '</span></header>' +
138
+ blocks +
139
+ '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending || !complete(item) ? ' disabled' : '') + '>' + (sending ? 'Sending…' : armed ? 'Tap again to send' : 'Send') + '</button>' +
140
+ '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
141
+ noteHtml(item) + '</article>';
142
+ }
143
+ function approvalCard(item) {
144
+ var a = item.approval || { tool: item.tool, summary: '', detail: '' };
145
+ var sending = !!Q.sending[item.id];
146
+ var armed = Q.armed[item.id];
147
+ return '<article class="sq-card" data-sq-card="' + esc(item.id) + '" data-kind="approval">' +
148
+ '<header class="sq-head"><h2 class="sq-title">' + esc(engine(item)) + ' wants to use ' + esc(a.tool || item.tool || 'a tool') + '</h2><span class="sq-secs" data-sq-secs="' + esc(item.id) + '">' + esc(timeLeft(secondsLeft(item))) + '</span></header>' +
149
+ '<pre class="sq-command">' + esc(a.summary || 'No details were sent with this request.') + '</pre>' +
150
+ (a.detail && a.detail !== a.summary ? '<details class="sq-details"><summary>Full command</summary><pre class="sq-command">' + esc(a.detail) + '</pre></details>' : '') +
151
+ '<div class="sq-acts"><button type="button" class="cx-btn primary" data-sq="allow" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + (armed === 'allow' ? 'Tap again to allow' : 'Allow once') + '</button>' +
152
+ '<button type="button" class="cx-btn sq-deny" data-sq="deny" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + (armed === 'deny' ? 'Tap again to deny' : 'Deny') + '</button>' +
153
+ '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
154
+ noteHtml(item) + '</article>';
155
+ }
156
+ function render() {
157
+ var active = document.activeElement;
158
+ var focus = active && active.hasAttribute && active.hasAttribute('data-sq-other') ? { id: active.getAttribute('data-sq-id'), q: active.getAttribute('data-q'), start: active.selectionStart, end: active.selectionEnd } : null;
159
+ host.innerHTML = Q.items.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i); }).join('');
160
+ host.hidden = !Q.items.length;
161
+ if (focus) {
162
+ var box = host.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
163
+ if (box) { box.focus({ preventScroll: true }); try { box.setSelectionRange(focus.start, focus.end); } catch (_e) {} }
164
+ }
165
+ clearInterval(Q.tick);
166
+ if (Q.items.length) Q.tick = setInterval(tickSeconds, 1000);
167
+ }
168
+ function itemOf(id) { for (var i = 0; i < Q.items.length; i++) if (Q.items[i].id === id) return Q.items[i]; return null; }
169
+ // The seconds move without a redraw (a redraw would drop a half-typed answer).
170
+ function tickSeconds() {
171
+ host.querySelectorAll('[data-sq-secs]').forEach(function (el) { var it = itemOf(el.getAttribute('data-sq-secs')); if (it) el.textContent = timeLeft(secondsLeft(it)); });
172
+ }
173
+ function patchCard(id) {
174
+ var item = itemOf(id), card = host.querySelector('[data-sq-card="' + id + '"]');
175
+ if (!item || !card) return render();
176
+ var send = card.querySelector('[data-sq="send"]');
177
+ if (send) { send.disabled = !!Q.sending[id] || !complete(item); send.textContent = Q.sending[id] ? 'Sending…' : Q.armed[id] === 'send' ? 'Tap again to send' : 'Send'; }
178
+ var allow = card.querySelector('[data-sq="allow"]'); if (allow) allow.textContent = Q.armed[id] === 'allow' ? 'Tap again to allow' : 'Allow once';
179
+ var deny = card.querySelector('[data-sq="deny"]'); if (deny) deny.textContent = Q.armed[id] === 'deny' ? 'Tap again to deny' : 'Deny';
180
+ var note = card.querySelector('[data-sq-note]'); if (note) { note.textContent = Q.notes[id] || ''; note.classList.toggle('hidden', !Q.notes[id]); }
181
+ }
182
+
183
+ // ── answering ─────────────────────────────────────────
184
+ var REFUSED = {
185
+ already_answered: 'Already answered, on the glasses or the Mac.', expired: 'Too late: the Mac took this back.',
186
+ handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
187
+ not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
188
+ };
189
+ function post(id, body, doneLine) {
190
+ Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
191
+ if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
192
+ body.clientAnswerId = Q.answerIds[id];
193
+ render();
194
+ api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
195
+ delete Q.sending[id];
196
+ Q.items = Q.items.filter(function (i) { return i.id !== id; });
197
+ render();
198
+ W.toast(doneLine);
199
+ schedule(500);
200
+ }, function (err) {
201
+ delete Q.sending[id];
202
+ var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
203
+ if (settled) {
204
+ Q.items = Q.items.filter(function (i) { return i.id !== id; });
205
+ render(); W.toast(REFUSED[err.code]); schedule(500); return;
206
+ }
207
+ // A timeout or a lost answer: the same clientAnswerId goes again on the next tap and the server replays it.
208
+ Q.notes[id] = REFUSED[err && err.code] || ('Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
209
+ render();
210
+ });
211
+ }
212
+ function sendAnswers(item) {
213
+ var picks = Q.picks[item.id] || [];
214
+ var answers = (item.questions || []).map(function (q, qi) {
215
+ var p = picks[qi] || { labels: [], other: '' };
216
+ var entry = { labels: q.options.map(function (o) { return o.label; }).filter(function (l) { return p.labels.indexOf(l) >= 0; }) };
217
+ if (p.other.trim()) entry.other = p.other;
218
+ return entry;
219
+ });
220
+ post(item.id, { answers: answers }, 'Sent to ' + engine(item) + '.');
221
+ }
222
+ host.addEventListener('click', function (e) {
223
+ var b = e.target.closest('[data-sq]');
224
+ if (!b || b.disabled) return;
225
+ var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
226
+ if (!item) return;
227
+ if (what === 'leave') {
228
+ Q.armed[id] = '';
229
+ if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
230
+ Q.sending[id] = 1; render();
231
+ api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: { clientAnswerId: Q.answerIds[id], handBack: true }, timeoutMs: 20000 }).then(function () {
232
+ delete Q.sending[id]; Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast('Left for the Mac.'); schedule(500);
233
+ }, function (err) {
234
+ delete Q.sending[id];
235
+ if (err && REFUSED[err.code] && err.code !== 'invalid_answer') { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast(REFUSED[err.code]); return; }
236
+ Q.notes[id] = 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
237
+ });
238
+ return;
239
+ }
240
+ // Tap, then tap again: the first tap only arms the button.
241
+ if (Q.armed[id] !== what) { Q.armed[id] = what; patchCard(id); return; }
242
+ if (what === 'send') return sendAnswers(item);
243
+ if (what === 'allow' || what === 'deny') return post(id, { decision: what }, what === 'allow' ? 'Allowed once.' : 'Denied.');
244
+ });
245
+ host.addEventListener('change', function (e) {
246
+ var t = e.target;
247
+ if (!t.hasAttribute('data-sq-pick')) return;
248
+ var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
249
+ var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], label = q.options[Number(t.getAttribute('data-o'))].label;
250
+ var p = Q.picks[id][qi];
251
+ if (q.multiSelect) p.labels = t.checked ? p.labels.concat([label]) : p.labels.filter(function (l) { return l !== label; });
252
+ else { p.labels = [label]; p.other = ''; var box = host.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; }
253
+ Q.armed[id] = '';
254
+ patchCard(id);
255
+ });
256
+ host.addEventListener('input', function (e) {
257
+ var t = e.target;
258
+ if (!t.hasAttribute('data-sq-other')) return;
259
+ var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
260
+ var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], p = Q.picks[id][qi];
261
+ p.other = t.value;
262
+ // Single choice: typing your own answer clears the picked option, as on the phone.
263
+ if (!q.multiSelect && t.value.trim() && p.labels.length) {
264
+ p.labels = [];
265
+ host.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; });
266
+ }
267
+ Q.armed[id] = '';
268
+ patchCard(id);
269
+ });
270
+
271
+ document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
272
+ W.onShell(function () { start(); });
273
+
274
+ W.sessionQuestions = { _state: Q, _take: take, _render: render, poll: poll };
275
+ })();