@gotcos/glasses-server 6.99.20 → 6.99.29

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 (65) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +48 -5
  5. package/server/lib/agent-session-output-images.ts +280 -0
  6. package/server/lib/agent-session-store.ts +3 -1
  7. package/server/lib/agent-session-turns.ts +18 -7
  8. package/server/lib/client-liveness.ts +38 -3
  9. package/server/lib/codex-subagent-activity.ts +36 -0
  10. package/server/lib/g2-turn-provenance.ts +679 -0
  11. package/server/lib/media-store.ts +41 -2
  12. package/server/lib/permission-broker.ts +94 -17
  13. package/server/lib/queue-turn-evidence.ts +19 -2
  14. package/server/lib/session-halt.ts +15 -1
  15. package/server/lib/session-stream-events.ts +4 -0
  16. package/server/lib/session-subagents.ts +155 -0
  17. package/server/lib/session-turn-attachments.ts +245 -0
  18. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  19. package/server/lib/thread-turn-queue.ts +10 -0
  20. package/server/lib/today-brief.ts +731 -0
  21. package/server/lib/turn-file-store.ts +202 -0
  22. package/server/lib/turn-footer.ts +81 -0
  23. package/server/lib/web-control-static.ts +226 -0
  24. package/server/lib/web-memories-bridge.ts +9 -8
  25. package/server/lib/web-pet-choice.ts +225 -0
  26. package/server/lib/web-pet.ts +226 -0
  27. package/server/lib/work-lens-rounds.ts +29 -4
  28. package/server/routes/agent-session-bindings.ts +164 -16
  29. package/server/routes/agent-sessions.ts +77 -8
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +30 -1
  32. package/server/routes/g2-turns.ts +46 -0
  33. package/server/routes/health.ts +9 -0
  34. package/server/routes/media.ts +32 -8
  35. package/server/routes/permission-broker.ts +47 -4
  36. package/server/routes/thread-turn-queue.ts +148 -8
  37. package/server/routes/today-brief.ts +148 -0
  38. package/server/routes/web-control.ts +110 -7
  39. package/server/routes/work-lens-rounds.ts +19 -5
  40. package/server/web-control/app.css +26 -5
  41. package/server/web-control/app.js +1351 -177
  42. package/server/web-control/control.css +1 -1
  43. package/server/web-control/index.html +31 -13
  44. package/server/web-control/memories/memory-workspace.js +1 -1
  45. package/server/web-control/panes/cos-chat.css +22 -0
  46. package/server/web-control/panes/cos-chat.js +264 -48
  47. package/server/web-control/panes/meetings.css +15 -2
  48. package/server/web-control/panes/meetings.js +20 -9
  49. package/server/web-control/panes/memories.css +291 -0
  50. package/server/web-control/panes/memories.js +2208 -0
  51. package/server/web-control/panes/questions.css +39 -1
  52. package/server/web-control/panes/questions.js +419 -64
  53. package/server/web-control/panes/reader-media.js +154 -0
  54. package/server/web-control/panes/sessions.css +2 -0
  55. package/server/web-control/panes/settings.js +136 -6
  56. package/server/web-control/panes/today-actions.css +167 -0
  57. package/server/web-control/panes/today-actions.js +1303 -0
  58. package/server/web-control/panes/work.css +54 -0
  59. package/server/web-control/panes/work.js +1037 -18
  60. package/server/web-control/shell.js +6 -2
  61. package/server/web-control/theme.js +0 -2
  62. package/server/web-control/memories/memories-app.js +0 -1561
  63. package/server/web-control/memories/memories-theme.css +0 -73
  64. package/server/web-control/memories/memories.html +0 -72
  65. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -7,12 +7,18 @@
7
7
  older server answers it 401, which would sign the page out, so the page never calls it there);
8
8
  - the broker is on (sessionQuestions.enabled);
9
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).
10
+ An open tab never makes the Mac hold a question back from its own pop-up (6.99.13, Miles's option B). What the page
11
+ adds (6.99.26, Miles 11/07: "Away rule, like phone") is its touch: `hold=1&presenceAgeMs=<ms>`, how long ago a touch
12
+ or a key landed on this page while it was in front. While the Mac's desk is idle, a question that starts within 30
13
+ minutes of that touch is held for the away hold (up to 10 minutes), answerable here and on the glasses or phone; at
14
+ the desk the Mac's own pop-up shows at once. No touch since the page opened sends no presence. The poll is a plain
15
+ fetch: a 401 stops the poll quietly and never signs the page out (the panes say so on their next read).
14
16
 
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
17
+ A question the Mac's own prompt waits on (`settled` with `waitingAtMac`, a held one that went back to the Mac) shows
18
+ read-only in the cards drawn for that session (Today's waiting sheet), with its words when this page saw them while
19
+ it was held: the server keeps codes only once a question settles. Nothing here can answer it.
20
+
21
+ Allow once, Deny and Submit are tap-then-confirm, as in the Even app: an approval runs a command on the Mac. Leave for
16
22
  the Mac hands the request back to the Mac's own dialog. Plan approvals never come here: the broker never holds
17
23
  ExitPlanMode (lib/permission-broker.ts UNBROKERED_TOOLS). */
18
24
  (function () {
@@ -28,14 +34,60 @@
28
34
  items: [], // pending items, oldest first
29
35
  picks: {}, // id -> [{ labels: [], other: '' }] per question
30
36
  armed: {}, // id -> 'send' | 'allow' | 'deny'
37
+ armedAt: {}, // id -> when it was armed (COSWeb.confirmReady)
31
38
  sending: {}, // id -> clientAnswerId while a POST is out
32
39
  notes: {}, // id -> one line under the card
33
40
  answerIds: {}, // id -> the clientAnswerId this page answers under (a retry replays it)
41
+ atMac: [], // settled items the Mac's own prompt still waits on, newest first
42
+ seen: {}, // id -> { questions: [text], summary } the words of a card this page drew while it was held
43
+ answered: {}, // id -> { sessionId, at } answered here or anywhere (settled `answered`), for two minutes
44
+ missing: {}, // id -> the question a Submit tap found unanswered, until the next pick
34
45
  timer: null,
35
46
  tick: null,
36
- stopped: false
47
+ stopped: false,
48
+ inflight: false, // a poll is on the wire
49
+ again: false, // something asked for a poll while one was out: one more as soon as it lands
50
+ lastPollAt: 0 // when the last poll went out (Date.now)
37
51
  };
38
52
  var DEFAULT_POLL_MS = 10000;
53
+ // 6.99.27 (Miles 11/07 09:29, "a big delay to see the questions on the homepage"): while this tab is visible on
54
+ // Today it reads every 3 s, whatever the server's idle pace (10 s). Before, a question parked just after an idle read
55
+ // waited up to 10 s plus the round trip for the next one. Hidden, the page reads nothing (visibilitychange below).
56
+ var TODAY_POLL_MS = 3000;
57
+ // A nudge (a session that just went to waiting, Today coming on screen) never reads more often than this.
58
+ var NUDGE_GAP_MS = 1000;
59
+ // Miles's words for a question at the Mac (11/07), one place: Today's Needs you reads them from here too.
60
+ var MAC_LINE = "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens).";
61
+ var ANSWERED_LINE = 'Answered. The session carries on.';
62
+ var ANSWERED_MS = 120000;
63
+
64
+ // ── this page's touch, for the Mac's away hold (6.99.26) ──
65
+ // A real touch, click or key while the tab is in front. Kept on both clocks; its age is the larger of the two, so a
66
+ // phone that slept never sends a touch younger than it is (the server's own rule, lib/client-liveness).
67
+ var PRESENCE_MAX_MS = 86400000;
68
+ var touch = { mono: null, wall: null };
69
+ function monoNow() { return typeof performance !== 'undefined' && performance && typeof performance.now === 'function' ? performance.now() : null; }
70
+ function presenceAge() {
71
+ if (touch.wall === null) return null;
72
+ var m = monoNow();
73
+ var age = Math.max(0, Date.now() - touch.wall, m !== null && touch.mono !== null ? m - touch.mono : 0);
74
+ age = Math.round(age);
75
+ return age <= PRESENCE_MAX_MS ? age : null;
76
+ }
77
+ function noteTouch(e) {
78
+ if (document.hidden || (e && e.isTrusted === false)) return;
79
+ var before = presenceAge();
80
+ touch.mono = monoNow(); touch.wall = Date.now();
81
+ // The first touch in a while reaches the Mac now, not at the next poll, so a question that starts a moment later
82
+ // is held for this page.
83
+ // QA 6.99.27 (N4): through nudge, so it keeps the 1 s gap and the one read on the wire like every other ask.
84
+ if (before === null || before > 60000) nudge();
85
+ }
86
+ ['pointerdown', 'touchstart', 'keydown'].forEach(function (t) { document.addEventListener(t, noteTouch, { capture: true, passive: true }); });
87
+ function pollUrl() {
88
+ var age = presenceAge();
89
+ return '/api/session-questions?client=web&hold=1' + (age === null ? '' : '&presenceAgeMs=' + age);
90
+ }
39
91
 
40
92
  function signedIn() { var shell = document.getElementById('app-shell'); return !!(shell && !shell.classList.contains('hidden')); }
41
93
  function answerId() {
@@ -61,46 +113,92 @@
61
113
  }, function () { return null; });
62
114
  }
63
115
  function usable(cap) { return !!(cap && cap.enabled === true && cap.webClient === true); }
116
+ // The capability for this poll. Once this server has said the web may poll, a poll never waits on /api/models again:
117
+ // an old answer is refreshed alongside the poll (6.99.27: after five minutes away, coming back read /api/models first
118
+ // and only then the questions). The first read of a page still waits for it, so an older server is never polled.
119
+ function capForPoll() {
120
+ if (usable(Q.cap)) { if (Date.now() - Q.capAt >= 5 * 60000) readCap(); return Promise.resolve(Q.cap); }
121
+ return readCap();
122
+ }
123
+ function onToday() { var st = W.state; return !!(st && st.pane === 'today'); }
124
+ // The wait before the next read: the server's pace, 3 s while one waits (an answer elsewhere clears it here too), and
125
+ // 3 s while this tab shows Today.
126
+ function nextWait(every) { return Q.items.length || onToday() ? Math.min(every, TODAY_POLL_MS) : every; }
64
127
  function schedule(ms) {
65
128
  clearTimeout(Q.timer);
66
129
  Q.timer = setTimeout(poll, ms);
67
130
  }
131
+ function landed() {
132
+ Q.inflight = false;
133
+ // QA 6.99.27 (N4): the read asked for while this one was out goes through nudge, so it waits out the 1 s gap too.
134
+ if (Q.again) { Q.again = false; nudge(); }
135
+ }
68
136
  function poll() {
69
137
  clearTimeout(Q.timer);
70
138
  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; }
139
+ // One read on the wire at a time: a second ask while one is out becomes one more read when it lands.
140
+ if (Q.inflight) { Q.again = true; return; }
141
+ Q.inflight = true; Q.again = false; Q.lastPollAt = Date.now();
142
+ capForPoll().then(function (cap) {
143
+ if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
144
+ return fetch(pollUrl(), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store' }).then(function (res) {
145
+ if (res.status === 401) { Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
146
+ if (!res.ok) { schedule(DEFAULT_POLL_MS); landed(); return; }
76
147
  return res.json().then(function (d) {
77
- if (!d || d.enabled === false) { clearCards(); schedule(60000); return; }
78
- take(Array.isArray(d.items) ? d.items : []);
148
+ if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
149
+ // Painted as soon as the read lands: take() draws the cards and repaints Today's Needs you and the pet.
150
+ take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
79
151
  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);
152
+ schedule(nextWait(every));
153
+ landed();
82
154
  });
83
- }, function () { schedule(DEFAULT_POLL_MS); });
84
- });
155
+ }, function () { schedule(DEFAULT_POLL_MS); landed(); });
156
+ }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
85
157
  }
86
- function start() { Q.stopped = false; poll(); }
158
+ // Read now, unless a read went out under NUDGE_GAP_MS ago (then at the end of that gap) or one is on the wire (then
159
+ // once more when it lands). Today calls this when a session goes to waiting, so its question shows at once.
160
+ function nudge() {
161
+ if (Q.stopped || document.hidden || !signedIn()) return;
162
+ if (Q.inflight) { Q.again = true; return; }
163
+ var since = Date.now() - Q.lastPollAt;
164
+ schedule(since >= NUDGE_GAP_MS ? 0 : NUDGE_GAP_MS - since);
165
+ }
166
+ // The first read of the page goes at once; after that (back from hidden, a new sign-in), through nudge (QA 6.99.27, N4).
167
+ function start() { Q.stopped = false; if (!Q.lastPollAt) poll(); else nudge(); }
87
168
  function stop() { clearTimeout(Q.timer); }
88
169
 
89
170
  // ── state from a poll ─────────────────────────────────
90
- function take(items) {
171
+ function take(items, settled) {
91
172
  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('|');
173
+ var atMac = (settled || []).filter(function (i) { return i && i.waitingAtMac === true && (i.kind === 'question' || i.kind === 'approval'); });
174
+ var before = Q.items.map(function (i) { return i.id; }).join('|') + '/' + Q.atMac.map(function (i) { return i.id; }).join('|');
175
+ var after = pending.map(function (i) { return i.id; }).join('|') + '/' + atMac.map(function (i) { return i.id; }).join('|');
176
+ // The words of a held card, kept while the server lists it either way, so one that goes back to the Mac still says
177
+ // what it asks. Only in memory.
178
+ pending.forEach(function (i) {
179
+ Q.seen[i.id] = { questions: (i.questions || []).map(function (q) { return String((q && q.question) || ''); }).filter(Boolean),
180
+ summary: i.approval && i.approval.summary ? String(i.approval.summary) : '' };
181
+ });
182
+ var listed = {}; pending.concat(atMac).forEach(function (i) { listed[i.id] = 1; });
183
+ Object.keys(Q.seen).forEach(function (id) { if (!listed[id]) delete Q.seen[id]; });
184
+ Q.atMac = atMac;
185
+ // Answered on any screen: the session list may still call the session waiting for a moment; this says it is not.
186
+ (settled || []).forEach(function (i) {
187
+ if (i && i.resolution === 'answered' && i.sessionId && !Q.answered[i.id]) Q.answered[i.id] = { sessionId: String(i.sessionId).toLowerCase(), at: Date.parse(i.settledAt) || Date.now() };
188
+ });
189
+ Object.keys(Q.answered).forEach(function (id) { if (Date.now() - Q.answered[id].at > ANSWERED_MS) delete Q.answered[id]; });
94
190
  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]; } });
191
+ 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]; delete Q.missing[id]; } });
96
192
  pending.forEach(function (i) {
97
193
  if (!Q.picks[i.id]) Q.picks[i.id] = (i.questions || []).map(function () { return { labels: [], other: '' }; });
98
194
  });
99
195
  Q.items = pending;
100
196
  // 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();
197
+ if (before !== after) { render(); changed(); }
102
198
  }
103
- function clearCards() { if (Q.items.length) { Q.items = []; render(); } }
199
+ function clearCards() { if (Q.items.length || Q.atMac.length) { Q.items = []; Q.atMac = []; render(); changed(); } }
200
+ // Today's Needs you says, per session, whether it can be answered here or waits on the Mac.
201
+ function changed() { if (W.today && W.today.questionsChanged) { try { W.today.questionsChanged(); } catch (_e) {} } }
104
202
 
105
203
  // ── drawing ───────────────────────────────────────────
106
204
  function complete(item) {
@@ -115,30 +213,91 @@
115
213
  var text = Q.notes[item.id] || '';
116
214
  return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
117
215
  }
118
- function questionCard(item) {
216
+ // The card reads like Claude Code's own question in the terminal (Miles 11/07): a strip naming each question, ☒ once
217
+ // it has an answer, then ✓ Submit; each question in bold, numbered options with their descriptions under them, Other
218
+ // as the last number, typed in place; keys 1 to 9 pick while a question has focus. Then the answers to review, and
219
+ // Submit, tap then tap again. Stacked rather than one question per page: Today's sheet is at most 480 px wide, folded
220
+ // or not, and a stacked card keeps every choice in view while you review. The strip jumps to each part.
221
+ function qLabel(q, qi) { return q && q.header ? String(q.header) : 'Question ' + (qi + 1); }
222
+ function answerWords(item, qi) {
223
+ var p = (Q.picks[item.id] || [])[qi] || { labels: [], other: '' };
224
+ var parts = p.labels.slice();
225
+ if (p.other.trim()) parts.push(p.other.trim());
226
+ return parts.join(', ');
227
+ }
228
+ function stepText(item, qi) { return (answerWords(item, qi) ? '☒ ' : '☐ ') + qLabel(item.questions[qi], qi); }
229
+ function stripHtml(item) {
230
+ var qs = item.questions || [];
231
+ if (qs.length < 2) return '';
232
+ return '<nav class="sq-steps" aria-label="Questions">' + qs.map(function (q, qi) {
233
+ return '<button type="button" class="sq-step" data-sq="step" data-sq-id="' + esc(item.id) + '" data-sq-step="' + qi + '">' + esc(stepText(item, qi)) + '</button>';
234
+ }).join('') + '<button type="button" class="sq-step" data-sq="step" data-sq-id="' + esc(item.id) + '" data-sq-step="submit">✓ Submit</button></nav>';
235
+ }
236
+ function reviewRows(item) {
237
+ return (item.questions || []).map(function (q, qi) {
238
+ var a = answerWords(item, qi);
239
+ return '<li><span class="sq-review-q">' + esc(qLabel(q, qi)) + '</span><span class="sq-review-a' + (a ? '' : ' empty') + '">' + esc(a || 'Not answered yet') + '</span></li>';
240
+ }).join('');
241
+ }
242
+ function reviewHtml(item) {
243
+ if ((item.questions || []).length < 2) return '';
244
+ return '<div class="sq-review" data-sq-review="' + esc(item.id) + '"><p class="sq-review-head">Your answers</p><ol class="sq-review-list">' + reviewRows(item) + '</ol></div>';
245
+ }
246
+ // 6.99.27 (Miles's phone, 11/07 09:32: "It's not allowing me to answer the Qs"): Submit was greyed out until every
247
+ // question had an answer, below the fold on a long card, and a multi-select said "Pick any that apply" though the broker
248
+ // takes no empty answer (validateQuestionAnswers: `unanswered`). Now the card says what each question needs, keeps
249
+ // its progress and the answer button in a footer that stays on screen, and a tap with something missing goes to it.
250
+ function needText(q) { return q.multiSelect ? 'Pick one or more.' : 'Pick one.'; }
251
+ function answeredCount(item) { var n = 0; (item.questions || []).forEach(function (_q, qi) { if (answerWords(item, qi)) n++; }); return n; }
252
+ function firstMissing(item, after) {
253
+ var qs = item.questions || [], n = qs.length;
254
+ for (var k = 1; k <= n; k++) { var qi = ((after == null ? -1 : after) + k) % n; if (!answerWords(item, qi)) return qi; }
255
+ return -1;
256
+ }
257
+ function progressText(item) {
258
+ var qs = item.questions || [], done = answeredCount(item), miss = Q.missing[item.id];
259
+ if (miss != null && !complete(item)) return qLabel(qs[miss], miss) + ' still needs an answer. ' + needText(qs[miss]);
260
+ if (qs.length < 2) return done ? 'Ready to submit.' : 'Not answered yet. ' + needText(qs[0]);
261
+ return done + ' of ' + qs.length + ' answered';
262
+ }
263
+ function sendText(id) {
264
+ if (Q.sending[id]) return 'Submitting…';
265
+ if (Q.armed[id] === 'send') return 'Tap again to submit';
266
+ var item = itemOf(id);
267
+ return item && (item.questions || []).length > 1 ? 'Review and submit' : 'Submit';
268
+ }
269
+ function questionCard(item, scope) {
119
270
  var picks = Q.picks[item.id] || [];
120
271
  var sending = !!Q.sending[item.id];
272
+ var single = (item.questions || []).length < 2;
121
273
  var blocks = (item.questions || []).map(function (q, qi) {
122
274
  var p = picks[qi] || { labels: [], other: '' };
123
275
  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>' : '') +
276
+ var name = (scope || 'sq') + '-' + item.id + '-' + qi;
277
+ return '<fieldset class="sq-q" data-sq-qblock="' + qi + '" data-sq-id="' + esc(item.id) + '"><legend class="sq-q-text">' + esc(q.question) +
278
+ (single && q.header ? ' <span class="sq-q-tag">' + esc(q.header) + '</span>' : '') + '</legend>' +
279
+ '<p class="sq-need" data-sq-need="' + qi + '" data-state="' + (answerWords(item, qi) ? 'done' : 'todo') + '">' + esc(needText(q)) + '</p>' +
127
280
  '<div class="sq-options">' + q.options.map(function (o, oi) {
128
281
  var on = p.labels.indexOf(o.label) >= 0;
129
282
  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' : '') + '>' +
283
+ '<span class="sq-num" aria-hidden="true">' + (oi + 1) + '.</span>' +
130
284
  '<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>';
285
+ }).join('') +
286
+ '<label class="sq-option sq-option-other"><span class="sq-num" aria-hidden="true">' + (q.options.length + 1) + '.</span>' +
287
+ '<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' : '') + '></label>' +
288
+ '</div></fieldset>';
134
289
  }).join('');
135
- var armed = Q.armed[item.id] === 'send';
136
290
  return '<article class="sq-card" data-sq-card="' + esc(item.id) + '" data-kind="question">' +
137
291
  '<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>' +
292
+ stripHtml(item) + blocks + reviewHtml(item) +
293
+ '<p class="sq-fine sq-keys">Type a number to pick.</p>' +
294
+ // Sticky to the bottom of the screen while the card is on it (questions.css): the progress, and the button that
295
+ // always answers: it never greys out for a missing answer, it goes to it.
296
+ '<div class="sq-foot" data-sq-foot="' + esc(item.id) + '">' +
297
+ '<p class="sq-progress" data-sq-progress="' + esc(item.id) + '" role="status"' + (Q.missing[item.id] != null && !complete(item) ? ' data-alert="1"' : '') + '>' + esc(progressText(item)) + '</p>' +
298
+ '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + sendText(item.id) + '</button>' +
140
299
  '<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>';
300
+ noteHtml(item) + '</div></article>';
142
301
  }
143
302
  function approvalCard(item) {
144
303
  var a = item.approval || { tool: item.tool, summary: '', detail: '' };
@@ -153,28 +312,88 @@
153
312
  '<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
313
  noteHtml(item) + '</article>';
155
314
  }
156
- function render() {
315
+ // A question or an approval the Mac's own prompt waits on: what it asks when this page saw it held, and where to answer.
316
+ // No buttons: nothing here can deliver an answer to that prompt.
317
+ function macCard(item) {
318
+ var words = Q.seen[item.id] || { questions: [], summary: '' };
319
+ var title = item.kind === 'approval' ? engine(item) + ' wants to use ' + (item.tool || 'a tool') : engine(item) + ' has a question';
320
+ var body = item.kind === 'approval'
321
+ ? (words.summary ? '<pre class="sq-command">' + esc(words.summary) + '</pre>' : '')
322
+ : words.questions.map(function (q) { return '<p class="sq-q-text sq-mac-q">' + esc(q) + '</p>'; }).join('');
323
+ return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="' + esc(item.kind) + '">' +
324
+ '<header class="sq-head"><h2 class="sq-title">' + esc(title) + '</h2></header>' + body +
325
+ '<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
326
+ }
327
+ function atMacFor(sessionId) {
328
+ // Every item names its session (the broker validates it), so no session asked for matches none: the top of every
329
+ // pane never draws a Mac card.
330
+ var want = String(sessionId || '').toLowerCase();
331
+ return Q.atMac.filter(function (i) { return String(i.sessionId || '').toLowerCase() === want; });
332
+ }
333
+ // True when this session's newest event here is an answer (from this page or any other screen) of the last two
334
+ // minutes, with nothing held for it now and nothing at the Mac since.
335
+ function answeredFor(sessionId) {
336
+ var want = String(sessionId || '').toLowerCase();
337
+ if (!want || itemsFor(want).length) return false;
338
+ var at = 0, macAt = 0;
339
+ Object.keys(Q.answered).forEach(function (id) { var a = Q.answered[id]; if (a.sessionId === want && Date.now() - a.at <= ANSWERED_MS) at = Math.max(at, a.at); });
340
+ atMacFor(want).forEach(function (i) { macAt = Math.max(macAt, Date.parse(i.settledAt) || Date.parse(i.createdAt) || 0); });
341
+ return at > 0 && at >= macAt;
342
+ }
343
+ function wordsOf(id) { var w = Q.seen[id]; return w ? { questions: w.questions.slice(), summary: w.summary } : null; }
344
+ // Other places can show the cards for one session (Today's overlay, panes/today-actions.js): the same items, picks,
345
+ // answer ids and posts, drawn into their own element. A mount is dropped when its element leaves the page.
346
+ var mounts = [];
347
+ function hostsOf() {
348
+ mounts = mounts.filter(function (m) { return m.el.isConnected !== false; });
349
+ return [{ el: host, sessionId: null, scope: 'sq' }].concat(mounts);
350
+ }
351
+ function itemsFor(sessionId) {
352
+ var want = String(sessionId || '').toLowerCase();
353
+ return Q.items.filter(function (i) { return !want || String(i.sessionId || '').toLowerCase() === want; });
354
+ }
355
+ function drawInto(m) {
157
356
  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;
357
+ var focus = active && active.hasAttribute && active.hasAttribute('data-sq-other') && m.el.contains && m.el.contains(active) ? { id: active.getAttribute('data-sq-id'), q: active.getAttribute('data-q'), start: active.selectionStart, end: active.selectionEnd } : null;
358
+ var list = itemsFor(m.sessionId), mac = atMacFor(m.sessionId);
359
+ m.el.innerHTML = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('') + mac.map(macCard).join('');
360
+ m.el.hidden = !list.length && !mac.length;
161
361
  if (focus) {
162
- var box = host.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
362
+ var box = m.el.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
163
363
  if (box) { box.focus({ preventScroll: true }); try { box.setSelectionRange(focus.start, focus.end); } catch (_e) {} }
164
364
  }
365
+ if (m.onChange) { try { m.onChange(list.length, mac.length); } catch (_e) {} }
366
+ }
367
+ function render() {
368
+ hostsOf().forEach(drawInto);
165
369
  clearInterval(Q.tick);
166
370
  if (Q.items.length) Q.tick = setInterval(tickSeconds, 1000);
167
371
  }
168
372
  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
373
  // The seconds move without a redraw (a redraw would drop a half-typed answer).
170
374
  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)); });
375
+ hostsOf().forEach(function (m) {
376
+ m.el.querySelectorAll('[data-sq-secs]').forEach(function (el) { var it = itemOf(el.getAttribute('data-sq-secs')); if (it) el.textContent = timeLeft(secondsLeft(it)); });
377
+ });
172
378
  }
173
379
  function patchCard(id) {
174
- var item = itemOf(id), card = host.querySelector('[data-sq-card="' + id + '"]');
175
- if (!item || !card) return render();
380
+ var item = itemOf(id), cards = [];
381
+ hostsOf().forEach(function (m) { var c = m.el.querySelector('[data-sq-card="' + id + '"]'); if (c) cards.push(c); });
382
+ if (!item || !cards.length) return render();
383
+ cards.forEach(function (card) { patchOne(card, id, item); });
384
+ }
385
+ function patchOne(card, id, item) {
176
386
  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'; }
387
+ if (send) { send.disabled = !!Q.sending[id]; send.textContent = sendText(id); }
388
+ var progress = card.querySelector('[data-sq-progress]');
389
+ if (progress && item.kind === 'question') {
390
+ progress.textContent = progressText(item);
391
+ if (Q.missing[id] != null && !complete(item)) progress.setAttribute('data-alert', '1'); else progress.removeAttribute('data-alert');
392
+ }
393
+ card.querySelectorAll('[data-sq-need]').forEach(function (need) { need.setAttribute('data-state', answerWords(item, Number(need.getAttribute('data-sq-need'))) ? 'done' : 'todo'); });
394
+ // The strip's ☐ and ☒ and the answers to review follow each pick, without redrawing the boxes being typed in.
395
+ card.querySelectorAll('[data-sq-step]').forEach(function (step) { var n = step.getAttribute('data-sq-step'); if (n !== 'submit') step.textContent = stepText(item, Number(n)); });
396
+ var review = card.querySelector('[data-sq-review] .sq-review-list'); if (review) review.innerHTML = reviewRows(item);
178
397
  var allow = card.querySelector('[data-sq="allow"]'); if (allow) allow.textContent = Q.armed[id] === 'allow' ? 'Tap again to allow' : 'Allow once';
179
398
  var deny = card.querySelector('[data-sq="deny"]'); if (deny) deny.textContent = Q.armed[id] === 'deny' ? 'Tap again to deny' : 'Deny';
180
399
  var note = card.querySelector('[data-sq-note]'); if (note) { note.textContent = Q.notes[id] || ''; note.classList.toggle('hidden', !Q.notes[id]); }
@@ -186,26 +405,47 @@
186
405
  handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
187
406
  not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
188
407
  };
408
+ // QA round 1: an answer that never reached the server (no HTTP status: offline, a dropped link, a timeout) says so in
409
+ // plain words, with the button to tap; the retry goes under the same clientAnswerId, so the server replays it.
410
+ function unreachable(item, what) {
411
+ // QA 6.99.27 (User 7): the button's own name. A card with more than one question says Review and submit.
412
+ var tap = what === 'leave' ? 'Leave for the Mac' : item && item.kind === 'approval' ? (what === 'deny' ? 'Deny' : 'Allow once')
413
+ : item && (item.questions || []).length > 1 ? 'Review and submit' : 'Submit';
414
+ return 'Could not reach your Mac. Tap ' + tap + ' to retry.';
415
+ }
416
+ // Answered, here or on another screen: recorded before the card is redrawn (a waiting sheet reads it as it repaints),
417
+ // then Today stops pointing at the Mac and reads its lists again.
418
+ function noteAnswered(item, id) { if (item && item.sessionId) Q.answered[id] = { sessionId: String(item.sessionId).toLowerCase(), at: Date.now() }; }
419
+ function answeredTold() {
420
+ changed();
421
+ if (W.today && W.today.refresh) { try { W.today.refresh(); } catch (_e) {} }
422
+ }
189
423
  function post(id, body, doneLine) {
190
424
  Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
191
425
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
192
426
  body.clientAnswerId = Q.answerIds[id];
193
427
  render();
428
+ var asked = itemOf(id);
194
429
  api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
195
430
  delete Q.sending[id];
431
+ noteAnswered(asked, id);
196
432
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
197
- render();
433
+ render(); answeredTold();
198
434
  W.toast(doneLine);
199
435
  schedule(500);
200
436
  }, function (err) {
201
437
  delete Q.sending[id];
202
438
  var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
203
439
  if (settled) {
440
+ if (err.code === 'already_answered') noteAnswered(asked, id);
204
441
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
205
- render(); W.toast(REFUSED[err.code]); schedule(500); return;
442
+ render();
443
+ if (err.code === 'already_answered') answeredTold(); else changed();
444
+ W.toast(REFUSED[err.code]); schedule(500); return;
206
445
  }
207
446
  // 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.');
447
+ Q.notes[id] = REFUSED[err && err.code] || (err && err.status == null ? unreachable(asked, body.decision || 'send')
448
+ : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
209
449
  render();
210
450
  });
211
451
  }
@@ -219,11 +459,51 @@
219
459
  });
220
460
  post(item.id, { answers: answers }, 'Sent to ' + engine(item) + '.');
221
461
  }
222
- host.addEventListener('click', function (e) {
462
+ // Scroll a part of the card into view: smooth unless reduced motion asks otherwise. Questions carry a scroll margin
463
+ // (questions.css) so the top bar and the sticky footer never cover them.
464
+ function bringIn(el, block) {
465
+ if (!el || !el.scrollIntoView) return;
466
+ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
467
+ el.scrollIntoView({ block: block || 'start', behavior: still ? 'auto' : 'smooth' });
468
+ }
469
+ function goToQuestion(card, qi) {
470
+ var to = card && card.querySelector('[data-sq-qblock="' + qi + '"]');
471
+ bringIn(to, 'start');
472
+ var f = to && to.querySelector('input');
473
+ if (f && f.focus && !f.disabled) f.focus({ preventScroll: true });
474
+ }
475
+ function goToSubmit(card) {
476
+ bringIn(card && card.querySelector('[data-sq-review]'), 'nearest');
477
+ var send = card && card.querySelector('[data-sq="send"]');
478
+ if (send && send.focus && !send.disabled) send.focus({ preventScroll: true });
479
+ }
480
+ // As in the terminal: a pick on a single-choice question moves on to the next one still unanswered, or to Submit.
481
+ var ADVANCE_MS = 160;
482
+ function advance(card, item, qi) {
483
+ if (!card || (item.questions || []).length < 2) return;
484
+ var next = firstMissing(item, qi);
485
+ setTimeout(function () { if (itemOf(item.id) && !Q.sending[item.id]) { if (next === -1) goToSubmit(card); else goToQuestion(card, next); } }, ADVANCE_MS);
486
+ }
487
+ function onClick(e) {
223
488
  var b = e.target.closest('[data-sq]');
224
489
  if (!b || b.disabled) return;
225
490
  var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
226
491
  if (!item) return;
492
+ var card = b.closest ? b.closest('.sq-card') : null;
493
+ if (what === 'step') {
494
+ // The strip brings a question to the top, or the answers and Submit into view.
495
+ var n = b.getAttribute('data-sq-step');
496
+ if (n === 'submit') goToSubmit(card); else goToQuestion(card, Number(n));
497
+ return;
498
+ }
499
+ if (what === 'send' && !complete(item)) {
500
+ // Something is missing: say which, and go to it. Never a greyed-out button that does nothing.
501
+ Q.armed[id] = '';
502
+ Q.missing[id] = firstMissing(item, null);
503
+ patchCard(id);
504
+ goToQuestion(card, Q.missing[id]);
505
+ return;
506
+ }
227
507
  if (what === 'leave') {
228
508
  Q.armed[id] = '';
229
509
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
@@ -232,28 +512,73 @@
232
512
  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
513
  }, function (err) {
234
514
  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();
515
+ if (err && REFUSED[err.code] && err.code !== 'invalid_answer') { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); changed(); W.toast(REFUSED[err.code]); return; }
516
+ Q.notes[id] = err && err.status == null ? unreachable(item, 'leave') : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
237
517
  });
238
518
  return;
239
519
  }
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; }
520
+ // Tap, then tap again: the first tap only arms the button, and the second counts only once the first has been on
521
+ // screen a moment (COSWeb.confirmReady, the rule Today's overlay uses), so a double tap never allows or sends.
522
+ if (Q.armed[id] !== what) {
523
+ Q.armed[id] = what; Q.armedAt[id] = Date.now(); patchCard(id);
524
+ if (what === 'send') goToSubmit(card); // the first tap is the review: the answers come into view
525
+ return;
526
+ }
527
+ if (W.confirmReady && !W.confirmReady(Q.armedAt[id])) return;
242
528
  if (what === 'send') return sendAnswers(item);
243
529
  if (what === 'allow' || what === 'deny') return post(id, { decision: what }, what === 'allow' ? 'Allowed once.' : 'Denied.');
244
- });
245
- host.addEventListener('change', function (e) {
530
+ }
531
+ function onChange(e) {
246
532
  var t = e.target;
247
533
  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 = ''; }
534
+ var id = t.getAttribute('data-sq-id'), qi = Number(t.getAttribute('data-q')), item = itemOf(id);
535
+ if (!applyPick(id, qi, Number(t.getAttribute('data-o')), !!t.checked)) return;
536
+ if (!item.questions[qi].multiSelect) advance(t.closest ? t.closest('.sq-card') : null, item, qi);
537
+ }
538
+ // One pick, from a tap or a number key: the model first, then every drawn copy of the card follows it.
539
+ function applyPick(id, qi, oi, checked) {
540
+ var item = itemOf(id); if (!item || Q.sending[id]) return false;
541
+ var q = item.questions[qi]; if (!q || !q.options[oi]) return false;
542
+ var label = q.options[oi].label, p = Q.picks[id][qi];
543
+ if (q.multiSelect) p.labels = checked ? (p.labels.indexOf(label) >= 0 ? p.labels : p.labels.concat([label])) : p.labels.filter(function (l) { return l !== label; });
544
+ else {
545
+ p.labels = [label]; p.other = '';
546
+ hostsOf().forEach(function (m) { var box = m.el.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; });
547
+ }
548
+ hostsOf().forEach(function (m) {
549
+ m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = p.labels.indexOf(q.options[Number(r.getAttribute('data-o'))].label) >= 0; });
550
+ });
551
+ // A change after Submit was armed takes the arm back: review, then submit.
253
552
  Q.armed[id] = '';
553
+ if (Q.missing[id] != null) delete Q.missing[id];
254
554
  patchCard(id);
255
- });
256
- host.addEventListener('input', function (e) {
555
+ return true;
556
+ }
557
+ // Keys 1 to 9 while a question has focus, as in the terminal: the option with that number, or the last number to type
558
+ // your own. Never while typing in Other, and never with a modifier held.
559
+ function onKey(e) {
560
+ var k = e.key;
561
+ if (!/^[1-9]$/.test(k) || e.metaKey || e.ctrlKey || e.altKey) return;
562
+ var t = e.target;
563
+ if (!t || !t.closest || (t.hasAttribute && t.hasAttribute('data-sq-other'))) return;
564
+ var block = t.closest('[data-sq-qblock]');
565
+ if (!block) return;
566
+ var id = block.getAttribute('data-sq-id'), qi = Number(block.getAttribute('data-sq-qblock')), item = itemOf(id);
567
+ var q = item && item.questions[qi], n = Number(k);
568
+ if (!q || Q.sending[id]) return;
569
+ if (n <= q.options.length) {
570
+ var label = q.options[n - 1].label, on = (Q.picks[id][qi].labels.indexOf(label) >= 0);
571
+ e.preventDefault();
572
+ applyPick(id, qi, n - 1, q.multiSelect ? !on : true);
573
+ var input = block.querySelector('[data-sq-pick][data-o="' + (n - 1) + '"]');
574
+ if (input && input.focus) input.focus({ preventScroll: true });
575
+ if (!q.multiSelect) advance(block.closest ? block.closest('.sq-card') : null, item, qi);
576
+ } else if (n === q.options.length + 1) {
577
+ var other = block.querySelector('[data-sq-other]');
578
+ if (other && other.focus) { e.preventDefault(); other.focus(); }
579
+ }
580
+ }
581
+ function onInput(e) {
257
582
  var t = e.target;
258
583
  if (!t.hasAttribute('data-sq-other')) return;
259
584
  var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
@@ -262,14 +587,44 @@
262
587
  // Single choice: typing your own answer clears the picked option, as on the phone.
263
588
  if (!q.multiSelect && t.value.trim() && p.labels.length) {
264
589
  p.labels = [];
265
- host.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; });
590
+ hostsOf().forEach(function (m) { m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; }); });
266
591
  }
267
592
  Q.armed[id] = '';
593
+ if (Q.missing[id] != null) delete Q.missing[id];
268
594
  patchCard(id);
269
- });
595
+ }
596
+ function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); el.addEventListener('keydown', onKey); }
597
+ listen(host);
598
+ // Draw this session's cards into `el` and keep them in step; `onChange(count)` hears each redraw.
599
+ function mount(el, sessionId, onChange) {
600
+ if (!el) return null;
601
+ var m = { el: el, sessionId: String(sessionId || '').toLowerCase(), scope: 'sqm' + (mounts.length + 1), onChange: onChange || null };
602
+ mounts.push(m); listen(el); drawInto(m);
603
+ if (!Q.stopped) nudge();
604
+ return { count: function () { return itemsFor(m.sessionId).length; }, unmount: function () { mounts = mounts.filter(function (x) { return x !== m; }); } };
605
+ }
606
+
607
+ // QA 6.99.27 (User 2): Today's own card (#sq-host) has a sticky footer, and iOS puts the keyboard over the layout
608
+ // viewport, so with Other focused the footer sat under it. The keyboard's height (what of the layout viewport the
609
+ // visible one leaves at the bottom) goes on the host as --sq-kb, and questions.css lifts the footer by it. Today's
610
+ // overlay needs none of this: its layer already follows the visible viewport (today-actions.js fit).
611
+ function keyboardInset() {
612
+ var vv = window.visualViewport;
613
+ if (!vv) return 0;
614
+ var inset = Math.round((window.innerHeight || 0) - (vv.height + vv.offsetTop));
615
+ return inset > 0 ? inset : 0;
616
+ }
617
+ function fitKeyboard() {
618
+ if (host.style && host.style.setProperty) host.style.setProperty('--sq-kb', keyboardInset() + 'px');
619
+ }
620
+ if (window.visualViewport) { window.visualViewport.addEventListener('resize', fitKeyboard); window.visualViewport.addEventListener('scroll', fitKeyboard); }
270
621
 
622
+ // Hidden: no reads at all (a phone in a pocket spends nothing). Visible again: one read at once (within the 1 s gap).
271
623
  document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
272
624
  W.onShell(function () { start(); });
625
+ // Coming to Today: a read at once (within the nudge gap), then every 3 s while it stays on screen.
626
+ if (W.onRoute) W.onRoute(function (r) { if (r && r.pane === 'today') nudge(); });
273
627
 
274
- W.sessionQuestions = { _state: Q, _take: take, _render: render, poll: poll };
628
+ W.sessionQuestions = { _state: Q, _take: take, _render: render, _presenceAge: presenceAge, _pollUrl: pollUrl, _key: onKey, _advanceMs: ADVANCE_MS, _todayPollMs: TODAY_POLL_MS, _fitKeyboard: fitKeyboard, poll: poll, nudge: nudge, mount: mount,
629
+ itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE };
275
630
  })();