@gotcos/glasses-server 6.99.25 → 6.99.33

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 (53) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +42 -9
  33. package/server/web-control/app.js +975 -114
  34. package/server/web-control/control.css +12 -6
  35. package/server/web-control/index.html +6 -1
  36. package/server/web-control/panes/cos-chat.css +27 -3
  37. package/server/web-control/panes/cos-chat.js +240 -46
  38. package/server/web-control/panes/meetings.css +5 -2
  39. package/server/web-control/panes/meetings.js +21 -10
  40. package/server/web-control/panes/memories.css +1 -1
  41. package/server/web-control/panes/memories.js +3 -8
  42. package/server/web-control/panes/questions.css +56 -2
  43. package/server/web-control/panes/questions.js +454 -56
  44. package/server/web-control/panes/reader-media.js +154 -0
  45. package/server/web-control/panes/sessions.css +20 -7
  46. package/server/web-control/panes/settings.js +34 -2
  47. package/server/web-control/panes/speakers.css +3 -5
  48. package/server/web-control/panes/speakers.js +7 -4
  49. package/server/web-control/panes/threads.js +3 -2
  50. package/server/web-control/panes/today-actions.css +81 -3
  51. package/server/web-control/panes/today-actions.js +527 -62
  52. package/server/web-control/panes/work.css +1 -1
  53. package/server/web-control/shell.js +3 -0
@@ -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 () {
@@ -32,11 +38,57 @@
32
38
  sending: {}, // id -> clientAnswerId while a POST is out
33
39
  notes: {}, // id -> one line under the card
34
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
35
45
  timer: null,
36
46
  tick: null,
37
- 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)
38
51
  };
39
52
  var DEFAULT_POLL_MS = 10000;
53
+ var POLL_TIMEOUT_MS = 15000;
54
+ // 6.99.27 (Miles 11/07 09:29, "a big delay to see the questions on the homepage"): while this tab is visible on
55
+ // Today it reads every 3 s, whatever the server's idle pace (10 s). Before, a question parked just after an idle read
56
+ // waited up to 10 s plus the round trip for the next one. Hidden, the page reads nothing (visibilitychange below).
57
+ var TODAY_POLL_MS = 3000;
58
+ // A nudge (a session that just went to waiting, Today coming on screen) never reads more often than this.
59
+ var NUDGE_GAP_MS = 1000;
60
+ // Miles's words for a question at the Mac (11/07), one place: Today's Needs you reads them from here too.
61
+ var MAC_LINE = "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens).";
62
+ var ANSWERED_LINE = 'Answered. The session carries on.';
63
+ var ANSWERED_MS = 120000;
64
+
65
+ // ── this page's touch, for the Mac's away hold (6.99.26) ──
66
+ // 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
67
+ // phone that slept never sends a touch younger than it is (the server's own rule, lib/client-liveness).
68
+ var PRESENCE_MAX_MS = 86400000;
69
+ var touch = { mono: null, wall: null };
70
+ function monoNow() { return typeof performance !== 'undefined' && performance && typeof performance.now === 'function' ? performance.now() : null; }
71
+ function presenceAge() {
72
+ if (touch.wall === null) return null;
73
+ var m = monoNow();
74
+ var age = Math.max(0, Date.now() - touch.wall, m !== null && touch.mono !== null ? m - touch.mono : 0);
75
+ age = Math.round(age);
76
+ return age <= PRESENCE_MAX_MS ? age : null;
77
+ }
78
+ function noteTouch(e) {
79
+ if (document.hidden || (e && e.isTrusted === false)) return;
80
+ var before = presenceAge();
81
+ touch.mono = monoNow(); touch.wall = Date.now();
82
+ // The first touch in a while reaches the Mac now, not at the next poll, so a question that starts a moment later
83
+ // is held for this page.
84
+ // 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.
85
+ if (before === null || before > 60000) nudge();
86
+ }
87
+ ['pointerdown', 'touchstart', 'keydown'].forEach(function (t) { document.addEventListener(t, noteTouch, { capture: true, passive: true }); });
88
+ function pollUrl() {
89
+ var age = presenceAge();
90
+ return '/api/session-questions?client=web&hold=1' + (age === null ? '' : '&presenceAgeMs=' + age);
91
+ }
40
92
 
41
93
  function signedIn() { var shell = document.getElementById('app-shell'); return !!(shell && !shell.classList.contains('hidden')); }
42
94
  function answerId() {
@@ -62,46 +114,112 @@
62
114
  }, function () { return null; });
63
115
  }
64
116
  function usable(cap) { return !!(cap && cap.enabled === true && cap.webClient === true); }
117
+ // The capability for this poll. Once this server has said the web may poll, a poll never waits on /api/models again:
118
+ // an old answer is refreshed alongside the poll (6.99.27: after five minutes away, coming back read /api/models first
119
+ // and only then the questions). The first read of a page still waits for it, so an older server is never polled.
120
+ function capForPoll() {
121
+ if (usable(Q.cap)) { if (Date.now() - Q.capAt >= 5 * 60000) readCap(); return Promise.resolve(Q.cap); }
122
+ return readCap();
123
+ }
124
+ function onToday() { var st = W.state; return !!(st && st.pane === 'today'); }
125
+ // The wait before the next read: the server's pace, 3 s while one waits (an answer elsewhere clears it here too), and
126
+ // 3 s while this tab shows Today.
127
+ function nextWait(every) { return Q.items.length || onToday() ? Math.min(every, TODAY_POLL_MS) : every; }
65
128
  function schedule(ms) {
66
129
  clearTimeout(Q.timer);
67
130
  Q.timer = setTimeout(poll, ms);
68
131
  }
132
+ function landed() {
133
+ Q.inflight = false;
134
+ // 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.
135
+ if (Q.again) { Q.again = false; nudge(); }
136
+ }
69
137
  function poll() {
70
138
  clearTimeout(Q.timer);
71
139
  if (Q.stopped || document.hidden || !signedIn()) return;
72
- readCap().then(function (cap) {
73
- if (!usable(cap)) { clearCards(); schedule(60000); return; }
74
- return fetch('/api/session-questions?client=web', { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store' }).then(function (res) {
75
- if (res.status === 401) { Q.stopped = true; clearCards(); return; }
76
- if (!res.ok) { schedule(DEFAULT_POLL_MS); return; }
140
+ // One read on the wire at a time: a second ask while one is out becomes one more read when it lands.
141
+ if (Q.inflight) { Q.again = true; return; }
142
+ Q.inflight = true; Q.again = false; Q.lastPollAt = Date.now();
143
+ capForPoll().then(function (cap) {
144
+ if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
145
+ // 6.99.32 (QA 6.99.30 canary W1): a Mac that hangs (asleep, a stale Tailscale route) never answered and never
146
+ // refused, so this read stayed in flight forever: no more polls, and the Mac never marked down. It gives up after
147
+ // POLL_TIMEOUT_MS, says the Mac is not answering, and polls again. The limit covers the body too (QA 6.99.32
148
+ // Skeptic W-1): a Mac that sleeps between the headers and the body otherwise held the poll for good.
149
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
150
+ var limit = ctrl ? setTimeout(function () { ctrl.abort(); }, POLL_TIMEOUT_MS) : null;
151
+ return fetch(pollUrl(), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
152
+ // QA 6.99.30 (Skeptic): this 3 s read is what Sessions and Meetings pages keep running, so it tells app.js the Mac
153
+ // answered (any status) or did not (below); otherwise a "down" mark stranded Submit, Allow and Deny for minutes.
154
+ if (W.noteServer) W.noteServer(true);
155
+ if (res.status === 401) { clearTimeout(limit); Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
156
+ if (!res.ok) { clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); return; }
77
157
  return res.json().then(function (d) {
78
- if (!d || d.enabled === false) { clearCards(); schedule(60000); return; }
79
- take(Array.isArray(d.items) ? d.items : []);
158
+ clearTimeout(limit);
159
+ if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
160
+ // Painted as soon as the read lands: take() draws the cards and repaints Today's Needs you and the pet.
161
+ take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
80
162
  var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
81
- // While one waits, read faster so an answer from the glasses or the Mac clears it here too.
82
- schedule(Q.items.length ? Math.min(every, 3000) : every);
163
+ schedule(nextWait(every));
164
+ landed();
165
+ }, function () {
166
+ // The body never finished (cut off, or the limit fired mid-body). The Mac did answer the headers, so it is not
167
+ // marked down; the poll is freed and goes again.
168
+ clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed();
83
169
  });
84
- }, function () { schedule(DEFAULT_POLL_MS); });
85
- });
170
+ }, function () {
171
+ // No answer at all: refused, unreachable, or the limit fired before the headers (the only abort this read has).
172
+ clearTimeout(limit);
173
+ if (W.noteServer) W.noteServer(false);
174
+ schedule(DEFAULT_POLL_MS); landed();
175
+ });
176
+ }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
177
+ }
178
+ // 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
179
+ // once more when it lands). Today calls this when a session goes to waiting, so its question shows at once.
180
+ function nudge() {
181
+ if (Q.stopped || document.hidden || !signedIn()) return;
182
+ if (Q.inflight) { Q.again = true; return; }
183
+ var since = Date.now() - Q.lastPollAt;
184
+ schedule(since >= NUDGE_GAP_MS ? 0 : NUDGE_GAP_MS - since);
86
185
  }
87
- function start() { Q.stopped = false; poll(); }
186
+ // 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).
187
+ function start() { Q.stopped = false; if (!Q.lastPollAt) poll(); else nudge(); }
88
188
  function stop() { clearTimeout(Q.timer); }
89
189
 
90
190
  // ── state from a poll ─────────────────────────────────
91
- function take(items) {
191
+ function take(items, settled) {
92
192
  var pending = items.filter(function (i) { return i && i.answerable !== false && (i.kind === 'question' || i.kind === 'approval'); });
93
- var before = Q.items.map(function (i) { return i.id; }).join('|');
94
- var after = pending.map(function (i) { return i.id; }).join('|');
193
+ var atMac = (settled || []).filter(function (i) { return i && i.waitingAtMac === true && (i.kind === 'question' || i.kind === 'approval'); });
194
+ var before = Q.items.map(function (i) { return i.id; }).join('|') + '/' + Q.atMac.map(function (i) { return i.id; }).join('|');
195
+ var after = pending.map(function (i) { return i.id; }).join('|') + '/' + atMac.map(function (i) { return i.id; }).join('|');
196
+ // The words of a held card, kept while the server lists it either way, so one that goes back to the Mac still says
197
+ // what it asks. Only in memory.
198
+ pending.forEach(function (i) {
199
+ Q.seen[i.id] = { questions: (i.questions || []).map(function (q) { return String((q && q.question) || ''); }).filter(Boolean),
200
+ summary: i.approval && i.approval.summary ? String(i.approval.summary) : '' };
201
+ });
202
+ var listed = {}; pending.concat(atMac).forEach(function (i) { listed[i.id] = 1; });
203
+ Object.keys(Q.seen).forEach(function (id) { if (!listed[id]) delete Q.seen[id]; });
204
+ Q.atMac = atMac;
205
+ // Answered on any screen: the session list may still call the session waiting for a moment; this says it is not.
206
+ (settled || []).forEach(function (i) {
207
+ 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() };
208
+ });
209
+ Object.keys(Q.answered).forEach(function (id) { if (Date.now() - Q.answered[id].at > ANSWERED_MS) delete Q.answered[id]; });
95
210
  var ids = {}; pending.forEach(function (i) { ids[i.id] = 1; });
96
- 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]; } });
211
+ 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]; } });
97
212
  pending.forEach(function (i) {
98
- if (!Q.picks[i.id]) Q.picks[i.id] = (i.questions || []).map(function () { return { labels: [], other: '' }; });
213
+ if (!Q.picks[i.id]) Q.picks[i.id] = keptPicks(i) || (i.questions || []).map(function () { return { labels: [], other: '' }; });
99
214
  });
215
+ forgetPicks(ids);
100
216
  Q.items = pending;
101
217
  // A card that is already drawn is never redrawn by a poll: a half-typed Other answer stays as it is.
102
- if (before !== after) render();
218
+ if (before !== after) { render(); changed(); }
103
219
  }
104
- function clearCards() { if (Q.items.length) { Q.items = []; render(); } }
220
+ function clearCards() { if (Q.items.length || Q.atMac.length) { Q.items = []; Q.atMac = []; render(); changed(); } }
221
+ // Today's Needs you says, per session, whether it can be answered here or waits on the Mac.
222
+ function changed() { if (W.today && W.today.questionsChanged) { try { W.today.questionsChanged(); } catch (_e) {} } }
105
223
 
106
224
  // ── drawing ───────────────────────────────────────────
107
225
  function complete(item) {
@@ -116,30 +234,92 @@
116
234
  var text = Q.notes[item.id] || '';
117
235
  return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
118
236
  }
237
+ // The card reads like Claude Code's own question in the terminal (Miles 11/07): a strip naming each question, ☒ once
238
+ // it has an answer, then ✓ Submit; each question in bold, numbered options with their descriptions under them, Other
239
+ // as the last number, typed in place; keys 1 to 9 pick while a question has focus. Then the answers to review, and
240
+ // Submit, tap then tap again. Stacked rather than one question per page: Today's sheet is at most 480 px wide, folded
241
+ // or not, and a stacked card keeps every choice in view while you review. The strip jumps to each part.
242
+ function qLabel(q, qi) { return q && q.header ? String(q.header) : 'Question ' + (qi + 1); }
243
+ function answerWords(item, qi) {
244
+ var p = (Q.picks[item.id] || [])[qi] || { labels: [], other: '' };
245
+ var parts = p.labels.slice();
246
+ if (p.other.trim()) parts.push(p.other.trim());
247
+ return parts.join(', ');
248
+ }
249
+ function stepText(item, qi) { return (answerWords(item, qi) ? '☒ ' : '☐ ') + qLabel(item.questions[qi], qi); }
250
+ function stripHtml(item) {
251
+ var qs = item.questions || [];
252
+ if (qs.length < 2) return '';
253
+ return '<nav class="sq-steps" aria-label="Questions">' + qs.map(function (q, qi) {
254
+ 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>';
255
+ }).join('') + '<button type="button" class="sq-step" data-sq="step" data-sq-id="' + esc(item.id) + '" data-sq-step="submit">✓ Submit</button></nav>';
256
+ }
257
+ function reviewRows(item) {
258
+ return (item.questions || []).map(function (q, qi) {
259
+ var a = answerWords(item, qi);
260
+ 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>';
261
+ }).join('');
262
+ }
263
+ function reviewHtml(item) {
264
+ if ((item.questions || []).length < 2) return '';
265
+ 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>';
266
+ }
267
+ // 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
268
+ // question had an answer, below the fold on a long card, and a multi-select said "Pick any that apply" though the broker
269
+ // takes no empty answer (validateQuestionAnswers: `unanswered`). Now the card says what each question needs, keeps
270
+ // its progress and the answer button in a footer that stays on screen, and a tap with something missing goes to it.
271
+ function needText(q) { return q.multiSelect ? 'Pick one or more.' : 'Pick one.'; }
272
+ function answeredCount(item) { var n = 0; (item.questions || []).forEach(function (_q, qi) { if (answerWords(item, qi)) n++; }); return n; }
273
+ function firstMissing(item, after) {
274
+ var qs = item.questions || [], n = qs.length;
275
+ for (var k = 1; k <= n; k++) { var qi = ((after == null ? -1 : after) + k) % n; if (!answerWords(item, qi)) return qi; }
276
+ return -1;
277
+ }
278
+ function progressText(item) {
279
+ var qs = item.questions || [], done = answeredCount(item), miss = Q.missing[item.id];
280
+ if (miss != null && !complete(item)) return qLabel(qs[miss], miss) + ' still needs an answer. ' + needText(qs[miss]);
281
+ if (qs.length < 2) return done ? 'Ready to submit.' : 'Not answered yet. ' + needText(qs[0]);
282
+ return done + ' of ' + qs.length + ' answered';
283
+ }
284
+ function sendText(id) {
285
+ if (Q.sending[id]) return 'Submitting…';
286
+ if (Q.armed[id] === 'send') return 'Tap again to submit';
287
+ var item = itemOf(id);
288
+ return item && (item.questions || []).length > 1 ? 'Review and submit' : 'Submit';
289
+ }
119
290
  function questionCard(item, scope) {
120
291
  var picks = Q.picks[item.id] || [];
121
292
  var sending = !!Q.sending[item.id];
293
+ var single = (item.questions || []).length < 2;
122
294
  var blocks = (item.questions || []).map(function (q, qi) {
123
295
  var p = picks[qi] || { labels: [], other: '' };
124
296
  var type = q.multiSelect ? 'checkbox' : 'radio';
125
297
  var name = (scope || 'sq') + '-' + item.id + '-' + qi;
126
- 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>' +
127
- (q.multiSelect ? '<p class="sq-fine">Pick any that apply.</p>' : '') +
298
+ return '<fieldset class="sq-q" data-sq-qblock="' + qi + '" data-sq-id="' + esc(item.id) + '"><legend class="sq-q-text">' + esc(q.question) +
299
+ (single && q.header ? ' <span class="sq-q-tag">' + esc(q.header) + '</span>' : '') + '</legend>' +
300
+ '<p class="sq-need" data-sq-need="' + qi + '" data-state="' + (answerWords(item, qi) ? 'done' : 'todo') + '">' + esc(needText(q)) + '</p>' +
128
301
  '<div class="sq-options">' + q.options.map(function (o, oi) {
129
302
  var on = p.labels.indexOf(o.label) >= 0;
130
303
  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' : '') + '>' +
304
+ '<span class="sq-num" aria-hidden="true">' + (oi + 1) + '.</span>' +
131
305
  '<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>';
132
- }).join('') + '</div>' +
133
- '<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' : '') + '>' +
134
- '</fieldset>';
306
+ }).join('') +
307
+ '<label class="sq-option sq-option-other"><span class="sq-num" aria-hidden="true">' + (q.options.length + 1) + '.</span>' +
308
+ '<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>' +
309
+ '</div></fieldset>';
135
310
  }).join('');
136
- var armed = Q.armed[item.id] === 'send';
137
311
  return '<article class="sq-card" data-sq-card="' + esc(item.id) + '" data-kind="question">' +
138
312
  '<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>' +
139
- blocks +
140
- '<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>' +
141
- '<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>' +
142
- noteHtml(item) + '</article>';
313
+ stripHtml(item) + blocks + reviewHtml(item) +
314
+ '<p class="sq-fine sq-keys">Type a number to pick.</p>' +
315
+ // Sticky to the bottom of the screen while the card is on it (questions.css): the progress, and the button that
316
+ // always answers: it never greys out for a missing answer, it goes to it.
317
+ '<div class="sq-foot" data-sq-foot="' + esc(item.id) + '">' +
318
+ '<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>' +
319
+ '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>' + sendText(item.id) + '</button>' +
320
+ '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
321
+ (offline() ? '<p class="sq-offline" role="status">' + esc(OFFLINE_LINE) + '</p>' : '') +
322
+ noteHtml(item) + '</div></article>';
143
323
  }
144
324
  function approvalCard(item) {
145
325
  var a = item.approval || { tool: item.tool, summary: '', detail: '' };
@@ -149,11 +329,41 @@
149
329
  '<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>' +
150
330
  '<pre class="sq-command">' + esc(a.summary || 'No details were sent with this request.') + '</pre>' +
151
331
  (a.detail && a.detail !== a.summary ? '<details class="sq-details"><summary>Full command</summary><pre class="sq-command">' + esc(a.detail) + '</pre></details>' : '') +
152
- '<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>' +
153
- '<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>' +
154
- '<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>' +
332
+ '<div class="sq-acts"><button type="button" class="cx-btn primary" data-sq="allow" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>' + (armed === 'allow' ? 'Tap again to allow' : 'Allow once') + '</button>' +
333
+ '<button type="button" class="cx-btn sq-deny" data-sq="deny" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>' + (armed === 'deny' ? 'Tap again to deny' : 'Deny') + '</button>' +
334
+ '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
335
+ (offline() ? '<p class="sq-offline" role="status">' + esc(OFFLINE_LINE) + '</p>' : '') +
155
336
  noteHtml(item) + '</article>';
156
337
  }
338
+ // 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.
339
+ // No buttons: nothing here can deliver an answer to that prompt.
340
+ function macCard(item) {
341
+ var words = Q.seen[item.id] || { questions: [], summary: '' };
342
+ var title = item.kind === 'approval' ? engine(item) + ' wants to use ' + (item.tool || 'a tool') : engine(item) + ' has a question';
343
+ var body = item.kind === 'approval'
344
+ ? (words.summary ? '<pre class="sq-command">' + esc(words.summary) + '</pre>' : '')
345
+ : words.questions.map(function (q) { return '<p class="sq-q-text sq-mac-q">' + esc(q) + '</p>'; }).join('');
346
+ return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="' + esc(item.kind) + '">' +
347
+ '<header class="sq-head"><h2 class="sq-title">' + esc(title) + '</h2></header>' + body +
348
+ '<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
349
+ }
350
+ function atMacFor(sessionId) {
351
+ // Every item names its session (the broker validates it), so no session asked for matches none: the top of every
352
+ // pane never draws a Mac card.
353
+ var want = String(sessionId || '').toLowerCase();
354
+ return Q.atMac.filter(function (i) { return String(i.sessionId || '').toLowerCase() === want; });
355
+ }
356
+ // True when this session's newest event here is an answer (from this page or any other screen) of the last two
357
+ // minutes, with nothing held for it now and nothing at the Mac since.
358
+ function answeredFor(sessionId) {
359
+ var want = String(sessionId || '').toLowerCase();
360
+ if (!want || itemsFor(want).length) return false;
361
+ var at = 0, macAt = 0;
362
+ 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); });
363
+ atMacFor(want).forEach(function (i) { macAt = Math.max(macAt, Date.parse(i.settledAt) || Date.parse(i.createdAt) || 0); });
364
+ return at > 0 && at >= macAt;
365
+ }
366
+ function wordsOf(id) { var w = Q.seen[id]; return w ? { questions: w.questions.slice(), summary: w.summary } : null; }
157
367
  // Other places can show the cards for one session (Today's overlay, panes/today-actions.js): the same items, picks,
158
368
  // answer ids and posts, drawn into their own element. A mount is dropped when its element leaves the page.
159
369
  var mounts = [];
@@ -168,15 +378,30 @@
168
378
  function drawInto(m) {
169
379
  var active = document.activeElement;
170
380
  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;
171
- var list = itemsFor(m.sessionId);
172
- m.el.innerHTML = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('');
173
- m.el.hidden = !list.length;
381
+ var list = itemsFor(m.sessionId), mac = atMacFor(m.sessionId);
382
+ var cards = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('') + mac.map(macCard).join('');
383
+ // The page's own host (over every pane): folded it is one line until tapped (questions.css .sq-banner, 6.99.30).
384
+ if (m.el === host && list.length) cards = bannerHtml(list) + '<div class="sq-cards' + (Q.bannerOpen ? ' open' : '') + '" id="sq-cards">' + cards + '</div>';
385
+ else if (m.el === host) Q.bannerOpen = false;
386
+ m.el.innerHTML = cards;
387
+ m.el.hidden = !list.length && !mac.length;
174
388
  if (focus) {
175
389
  var box = m.el.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
176
390
  if (box) { box.focus({ preventScroll: true }); try { box.setSelectionRange(focus.start, focus.end); } catch (_e) {} }
177
391
  }
178
- if (m.onChange) { try { m.onChange(list.length); } catch (_e) {} }
392
+ if (m.onChange) { try { m.onChange(list.length, mac.length); } catch (_e) {} }
393
+ }
394
+ // 6.99.30 (canary): "1 question for you. Answer", one line over the pane on a folded phone; a tap opens the cards.
395
+ function bannerHtml(list) {
396
+ var qs = list.filter(function (i) { return i.kind !== 'approval'; }).length, n = list.length;
397
+ var noun = qs === n ? (n === 1 ? 'question' : 'questions') : qs === 0 ? (n === 1 ? 'approval' : 'approvals') : (n === 1 ? 'request' : 'requests');
398
+ return '<button type="button" class="sq-banner" data-sq="banner" aria-expanded="' + (Q.bannerOpen ? 'true' : 'false') + '" aria-controls="sq-cards">' +
399
+ '<span class="sq-banner-text">' + esc(n + ' ' + noun + ' for you.') + '</span><span class="sq-banner-act">' + (Q.bannerOpen ? 'Hide' : 'Answer') + '</span></button>';
179
400
  }
401
+ // 6.99.30 (canary): with the Mac not answering (app.js noteServer), Submit, Allow and Deny wait and say why, instead of
402
+ // offering a tap that cannot land.
403
+ function offline() { return !!(W.serverUp && W.serverUp() === false); }
404
+ var OFFLINE_LINE = 'Your Mac is not answering. You can answer here once it is back.';
180
405
  function render() {
181
406
  hostsOf().forEach(drawInto);
182
407
  clearInterval(Q.tick);
@@ -197,7 +422,16 @@
197
422
  }
198
423
  function patchOne(card, id, item) {
199
424
  var send = card.querySelector('[data-sq="send"]');
200
- if (send) { send.disabled = !!Q.sending[id] || !complete(item); send.textContent = Q.sending[id] ? 'Sending…' : Q.armed[id] === 'send' ? 'Tap again to send' : 'Send'; }
425
+ if (send) { send.disabled = !!Q.sending[id] || offline(); send.textContent = sendText(id); }
426
+ var progress = card.querySelector('[data-sq-progress]');
427
+ if (progress && item.kind === 'question') {
428
+ progress.textContent = progressText(item);
429
+ if (Q.missing[id] != null && !complete(item)) progress.setAttribute('data-alert', '1'); else progress.removeAttribute('data-alert');
430
+ }
431
+ card.querySelectorAll('[data-sq-need]').forEach(function (need) { need.setAttribute('data-state', answerWords(item, Number(need.getAttribute('data-sq-need'))) ? 'done' : 'todo'); });
432
+ // The strip's ☐ and ☒ and the answers to review follow each pick, without redrawing the boxes being typed in.
433
+ card.querySelectorAll('[data-sq-step]').forEach(function (step) { var n = step.getAttribute('data-sq-step'); if (n !== 'submit') step.textContent = stepText(item, Number(n)); });
434
+ var review = card.querySelector('[data-sq-review] .sq-review-list'); if (review) review.innerHTML = reviewRows(item);
201
435
  var allow = card.querySelector('[data-sq="allow"]'); if (allow) allow.textContent = Q.armed[id] === 'allow' ? 'Tap again to allow' : 'Allow once';
202
436
  var deny = card.querySelector('[data-sq="deny"]'); if (deny) deny.textContent = Q.armed[id] === 'deny' ? 'Tap again to deny' : 'Deny';
203
437
  var note = card.querySelector('[data-sq-note]'); if (note) { note.textContent = Q.notes[id] || ''; note.classList.toggle('hidden', !Q.notes[id]); }
@@ -209,26 +443,47 @@
209
443
  handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
210
444
  not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
211
445
  };
446
+ // QA round 1: an answer that never reached the server (no HTTP status: offline, a dropped link, a timeout) says so in
447
+ // plain words, with the button to tap; the retry goes under the same clientAnswerId, so the server replays it.
448
+ function unreachable(item, what) {
449
+ // QA 6.99.27 (User 7): the button's own name. A card with more than one question says Review and submit.
450
+ var tap = what === 'leave' ? 'Leave for the Mac' : item && item.kind === 'approval' ? (what === 'deny' ? 'Deny' : 'Allow once')
451
+ : item && (item.questions || []).length > 1 ? 'Review and submit' : 'Submit';
452
+ return 'Could not reach your Mac. Tap ' + tap + ' to retry.';
453
+ }
454
+ // Answered, here or on another screen: recorded before the card is redrawn (a waiting sheet reads it as it repaints),
455
+ // then Today stops pointing at the Mac and reads its lists again.
456
+ function noteAnswered(item, id) { if (item && item.sessionId) Q.answered[id] = { sessionId: String(item.sessionId).toLowerCase(), at: Date.now() }; }
457
+ function answeredTold() {
458
+ changed();
459
+ if (W.today && W.today.refresh) { try { W.today.refresh(); } catch (_e) {} }
460
+ }
212
461
  function post(id, body, doneLine) {
213
462
  Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
214
463
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
215
464
  body.clientAnswerId = Q.answerIds[id];
216
465
  render();
466
+ var asked = itemOf(id);
217
467
  api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
218
468
  delete Q.sending[id];
469
+ noteAnswered(asked, id);
219
470
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
220
- render();
471
+ render(); answeredTold();
221
472
  W.toast(doneLine);
222
473
  schedule(500);
223
474
  }, function (err) {
224
475
  delete Q.sending[id];
225
476
  var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
226
477
  if (settled) {
478
+ if (err.code === 'already_answered') noteAnswered(asked, id);
227
479
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
228
- render(); W.toast(REFUSED[err.code]); schedule(500); return;
480
+ render();
481
+ if (err.code === 'already_answered') answeredTold(); else changed();
482
+ W.toast(REFUSED[err.code]); schedule(500); return;
229
483
  }
230
484
  // A timeout or a lost answer: the same clientAnswerId goes again on the next tap and the server replays it.
231
- Q.notes[id] = REFUSED[err && err.code] || ('Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
485
+ Q.notes[id] = REFUSED[err && err.code] || (err && err.status == null ? unreachable(asked, body.decision || 'send')
486
+ : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
232
487
  render();
233
488
  });
234
489
  }
@@ -242,11 +497,52 @@
242
497
  });
243
498
  post(item.id, { answers: answers }, 'Sent to ' + engine(item) + '.');
244
499
  }
500
+ // Scroll a part of the card into view: smooth unless reduced motion asks otherwise. Questions carry a scroll margin
501
+ // (questions.css) so the top bar and the sticky footer never cover them.
502
+ function bringIn(el, block) {
503
+ if (!el || !el.scrollIntoView) return;
504
+ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
505
+ el.scrollIntoView({ block: block || 'start', behavior: still ? 'auto' : 'smooth' });
506
+ }
507
+ function goToQuestion(card, qi) {
508
+ var to = card && card.querySelector('[data-sq-qblock="' + qi + '"]');
509
+ bringIn(to, 'start');
510
+ var f = to && to.querySelector('input');
511
+ if (f && f.focus && !f.disabled) f.focus({ preventScroll: true });
512
+ }
513
+ function goToSubmit(card) {
514
+ bringIn(card && card.querySelector('[data-sq-review]'), 'nearest');
515
+ var send = card && card.querySelector('[data-sq="send"]');
516
+ if (send && send.focus && !send.disabled) send.focus({ preventScroll: true });
517
+ }
518
+ // As in the terminal: a pick on a single-choice question moves on to the next one still unanswered, or to Submit.
519
+ var ADVANCE_MS = 160;
520
+ function advance(card, item, qi) {
521
+ if (!card || (item.questions || []).length < 2) return;
522
+ var next = firstMissing(item, qi);
523
+ setTimeout(function () { if (itemOf(item.id) && !Q.sending[item.id]) { if (next === -1) goToSubmit(card); else goToQuestion(card, next); } }, ADVANCE_MS);
524
+ }
245
525
  function onClick(e) {
246
526
  var b = e.target.closest('[data-sq]');
247
527
  if (!b || b.disabled) return;
528
+ if (b.getAttribute('data-sq') === 'banner') { Q.bannerOpen = !Q.bannerOpen; drawInto(hostsOf()[0]); return; }
248
529
  var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
249
530
  if (!item) return;
531
+ var card = b.closest ? b.closest('.sq-card') : null;
532
+ if (what === 'step') {
533
+ // The strip brings a question to the top, or the answers and Submit into view.
534
+ var n = b.getAttribute('data-sq-step');
535
+ if (n === 'submit') goToSubmit(card); else goToQuestion(card, Number(n));
536
+ return;
537
+ }
538
+ if (what === 'send' && !complete(item)) {
539
+ // Something is missing: say which, and go to it. Never a greyed-out button that does nothing.
540
+ Q.armed[id] = '';
541
+ Q.missing[id] = firstMissing(item, null);
542
+ patchCard(id);
543
+ goToQuestion(card, Q.missing[id]);
544
+ return;
545
+ }
250
546
  if (what === 'leave') {
251
547
  Q.armed[id] = '';
252
548
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
@@ -255,14 +551,18 @@
255
551
  delete Q.sending[id]; Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast('Left for the Mac.'); schedule(500);
256
552
  }, function (err) {
257
553
  delete Q.sending[id];
258
- 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; }
259
- Q.notes[id] = 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
554
+ 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; }
555
+ Q.notes[id] = err && err.status == null ? unreachable(item, 'leave') : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
260
556
  });
261
557
  return;
262
558
  }
263
559
  // Tap, then tap again: the first tap only arms the button, and the second counts only once the first has been on
264
560
  // screen a moment (COSWeb.confirmReady, the rule Today's overlay uses), so a double tap never allows or sends.
265
- if (Q.armed[id] !== what) { Q.armed[id] = what; Q.armedAt[id] = Date.now(); patchCard(id); return; }
561
+ if (Q.armed[id] !== what) {
562
+ Q.armed[id] = what; Q.armedAt[id] = Date.now(); patchCard(id);
563
+ if (what === 'send') goToSubmit(card); // the first tap is the review: the answers come into view
564
+ return;
565
+ }
266
566
  if (W.confirmReady && !W.confirmReady(Q.armedAt[id])) return;
267
567
  if (what === 'send') return sendAnswers(item);
268
568
  if (what === 'allow' || what === 'deny') return post(id, { decision: what }, what === 'allow' ? 'Allowed once.' : 'Denied.');
@@ -270,16 +570,79 @@
270
570
  function onChange(e) {
271
571
  var t = e.target;
272
572
  if (!t.hasAttribute('data-sq-pick')) return;
273
- var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
274
- var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], label = q.options[Number(t.getAttribute('data-o'))].label;
275
- var p = Q.picks[id][qi];
276
- if (q.multiSelect) p.labels = t.checked ? p.labels.concat([label]) : p.labels.filter(function (l) { return l !== label; });
573
+ var id = t.getAttribute('data-sq-id'), qi = Number(t.getAttribute('data-q')), item = itemOf(id);
574
+ if (!applyPick(id, qi, Number(t.getAttribute('data-o')), !!t.checked)) return;
575
+ if (!item.questions[qi].multiSelect) advance(t.closest ? t.closest('.sq-card') : null, item, qi);
576
+ }
577
+ // One pick, from a tap or a number key: the model first, then every drawn copy of the card follows it.
578
+ function applyPick(id, qi, oi, checked) {
579
+ var item = itemOf(id); if (!item || Q.sending[id]) return false;
580
+ var q = item.questions[qi]; if (!q || !q.options[oi]) return false;
581
+ var label = q.options[oi].label, p = Q.picks[id][qi];
582
+ 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; });
277
583
  else {
278
584
  p.labels = [label]; p.other = '';
279
585
  hostsOf().forEach(function (m) { var box = m.el.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; });
280
586
  }
587
+ hostsOf().forEach(function (m) {
588
+ 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; });
589
+ });
590
+ // A change after Submit was armed takes the arm back: review, then submit.
281
591
  Q.armed[id] = '';
592
+ if (Q.missing[id] != null) delete Q.missing[id];
593
+ keepPicks(id);
282
594
  patchCard(id);
595
+ return true;
596
+ }
597
+ // 6.99.30 (QA): a reload mid-answer keeps the picks and the typed Other words, per question id, for this tab only
598
+ // (sessionStorage). Restored only onto the same question (the same number of parts, and labels it still offers);
599
+ // dropped once the question is answered or gone.
600
+ var PICKS_KEY = 'cos.web.questions.picks';
601
+ function picksStore() { try { var v = JSON.parse(window.sessionStorage.getItem(PICKS_KEY) || '{}'); return v && typeof v === 'object' ? v : {}; } catch (_e) { return {}; } }
602
+ function writePicks(all) { try { window.sessionStorage.setItem(PICKS_KEY, JSON.stringify(all)); } catch (_e) {} }
603
+ function keepPicks(id) {
604
+ var all = picksStore(), p = Q.picks[id];
605
+ if (!p) return;
606
+ all[id] = p.map(function (x) { return { labels: x.labels.slice(0, 20), other: String(x.other || '').slice(0, 2000) }; });
607
+ writePicks(all);
608
+ }
609
+ function keptPicks(item) {
610
+ var kept = picksStore()[item.id], qs = item.questions || [];
611
+ if (!Array.isArray(kept) || kept.length !== qs.length) return null;
612
+ return qs.map(function (q, qi) {
613
+ var k = kept[qi] || {}, labels = (q.options || []).map(function (o) { return o.label; });
614
+ return { labels: (Array.isArray(k.labels) ? k.labels : []).filter(function (l) { return labels.indexOf(l) >= 0; }), other: typeof k.other === 'string' ? k.other : '' };
615
+ });
616
+ }
617
+ function forgetPicks(live) {
618
+ var all = picksStore(), gone = Object.keys(all).filter(function (id) { return !live[id]; });
619
+ if (!gone.length) return;
620
+ gone.forEach(function (id) { delete all[id]; });
621
+ writePicks(all);
622
+ }
623
+ // Keys 1 to 9 while a question has focus, as in the terminal: the option with that number, or the last number to type
624
+ // your own. Never while typing in Other, and never with a modifier held.
625
+ function onKey(e) {
626
+ var k = e.key;
627
+ if (!/^[1-9]$/.test(k) || e.metaKey || e.ctrlKey || e.altKey) return;
628
+ var t = e.target;
629
+ if (!t || !t.closest || (t.hasAttribute && t.hasAttribute('data-sq-other'))) return;
630
+ var block = t.closest('[data-sq-qblock]');
631
+ if (!block) return;
632
+ var id = block.getAttribute('data-sq-id'), qi = Number(block.getAttribute('data-sq-qblock')), item = itemOf(id);
633
+ var q = item && item.questions[qi], n = Number(k);
634
+ if (!q || Q.sending[id]) return;
635
+ if (n <= q.options.length) {
636
+ var label = q.options[n - 1].label, on = (Q.picks[id][qi].labels.indexOf(label) >= 0);
637
+ e.preventDefault();
638
+ applyPick(id, qi, n - 1, q.multiSelect ? !on : true);
639
+ var input = block.querySelector('[data-sq-pick][data-o="' + (n - 1) + '"]');
640
+ if (input && input.focus) input.focus({ preventScroll: true });
641
+ if (!q.multiSelect) advance(block.closest ? block.closest('.sq-card') : null, item, qi);
642
+ } else if (n === q.options.length + 1) {
643
+ var other = block.querySelector('[data-sq-other]');
644
+ if (other && other.focus) { e.preventDefault(); other.focus(); }
645
+ }
283
646
  }
284
647
  function onInput(e) {
285
648
  var t = e.target;
@@ -287,27 +650,62 @@
287
650
  var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
288
651
  var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], p = Q.picks[id][qi];
289
652
  p.other = t.value;
653
+ // 6.99.30 (QA): the same card drawn on Today and in the sheet keeps one answer: the words go into every copy.
654
+ hostsOf().forEach(function (m) {
655
+ m.el.querySelectorAll('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (box) { if (box !== t) box.value = t.value; });
656
+ });
290
657
  // Single choice: typing your own answer clears the picked option, as on the phone.
291
658
  if (!q.multiSelect && t.value.trim() && p.labels.length) {
292
659
  p.labels = [];
293
660
  hostsOf().forEach(function (m) { m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; }); });
294
661
  }
295
662
  Q.armed[id] = '';
663
+ if (Q.missing[id] != null) delete Q.missing[id];
664
+ keepPicks(id);
296
665
  patchCard(id);
297
666
  }
298
- function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); }
667
+ function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); el.addEventListener('keydown', onKey); }
299
668
  listen(host);
300
669
  // Draw this session's cards into `el` and keep them in step; `onChange(count)` hears each redraw.
301
670
  function mount(el, sessionId, onChange) {
302
671
  if (!el) return null;
303
672
  var m = { el: el, sessionId: String(sessionId || '').toLowerCase(), scope: 'sqm' + (mounts.length + 1), onChange: onChange || null };
304
673
  mounts.push(m); listen(el); drawInto(m);
305
- if (!Q.stopped) { clearTimeout(Q.timer); Q.timer = setTimeout(poll, 0); }
674
+ if (!Q.stopped) nudge();
306
675
  return { count: function () { return itemsFor(m.sessionId).length; }, unmount: function () { mounts = mounts.filter(function (x) { return x !== m; }); } };
307
676
  }
308
677
 
678
+ // QA 6.99.27 (User 2): Today's own card (#sq-host) has a sticky footer, and the keyboard covers the bottom of the
679
+ // layout viewport, so with Other focused the footer sat under it. What covers the bottom goes on the host as --sq-kb
680
+ // and questions.css lifts the footer by it. 6.99.30: read the one way Control Web reads the visible viewport (app.js
681
+ // COSWeb.viewportFit, which Today's sheet uses too): the layout viewport under the visible box, in layout px, so a
682
+ // pinch zoom alone lifts nothing and a page the browser panned to the field lifts by what is really under it.
683
+ // The card in Today's sheet (.ta-q) needs none of this: the sheet itself is the visible box (today-actions.js fit).
684
+ function keyboardInset() {
685
+ var v = W.viewportFit ? W.viewportFit() : null;
686
+ return v && v.vv ? v.below : 0;
687
+ }
688
+ function fitKeyboard() {
689
+ var inset = keyboardInset();
690
+ if (host.style && host.style.setProperty) host.style.setProperty('--sq-kb', inset + 'px');
691
+ // A keyboard up (120 px or more, or any strip under a field of the card): the footer stops sticking (questions.css).
692
+ var v = W.viewportFit ? W.viewportFit() : null, a = document.activeElement;
693
+ var typing = !!(a && host.contains && host.contains(a) && a.hasAttribute && a.hasAttribute('data-sq-other'));
694
+ if (host.classList) host.classList.toggle('sq-kb-up', !!(v && v.vv && (v.inset >= 120 || (typing && v.inset > 0))));
695
+ }
696
+ if (window.visualViewport) { window.visualViewport.addEventListener('resize', fitKeyboard); window.visualViewport.addEventListener('scroll', fitKeyboard); }
697
+ window.addEventListener('resize', fitKeyboard); window.addEventListener('orientationchange', fitKeyboard);
698
+ window.addEventListener('pageshow', fitKeyboard);
699
+ host.addEventListener('focusin', fitKeyboard); host.addEventListener('focusout', function () { setTimeout(fitKeyboard, 0); });
700
+ // The Mac going away or coming back redraws the cards at once (their buttons wait while it is away).
701
+ if (W.onServer) W.onServer(function () { render(); });
702
+
703
+ // Hidden: no reads at all (a phone in a pocket spends nothing). Visible again: one read at once (within the 1 s gap).
309
704
  document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
310
705
  W.onShell(function () { start(); });
706
+ // Coming to Today: a read at once (within the nudge gap), then every 3 s while it stays on screen.
707
+ if (W.onRoute) W.onRoute(function (r) { if (r && r.pane === 'today') nudge(); });
311
708
 
312
- W.sessionQuestions = { _state: Q, _take: take, _render: render, poll: poll, mount: mount, itemsFor: itemsFor };
709
+ 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,
710
+ itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE };
313
711
  })();