@gotcos/glasses-server 6.99.25 → 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.
- package/CHANGELOG.md +166 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +2 -0
- package/server/web-control/app.js +738 -102
- package/server/web-control/index.html +5 -1
- package/server/web-control/panes/cos-chat.css +18 -0
- package/server/web-control/panes/cos-chat.js +239 -45
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +368 -51
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/today-actions.css +62 -4
- package/server/web-control/panes/today-actions.js +384 -53
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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,56 @@
|
|
|
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
|
+
// 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
|
+
}
|
|
40
91
|
|
|
41
92
|
function signedIn() { var shell = document.getElementById('app-shell'); return !!(shell && !shell.classList.contains('hidden')); }
|
|
42
93
|
function answerId() {
|
|
@@ -62,46 +113,92 @@
|
|
|
62
113
|
}, function () { return null; });
|
|
63
114
|
}
|
|
64
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; }
|
|
65
127
|
function schedule(ms) {
|
|
66
128
|
clearTimeout(Q.timer);
|
|
67
129
|
Q.timer = setTimeout(poll, ms);
|
|
68
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
|
+
}
|
|
69
136
|
function poll() {
|
|
70
137
|
clearTimeout(Q.timer);
|
|
71
138
|
if (Q.stopped || document.hidden || !signedIn()) return;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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; }
|
|
77
147
|
return res.json().then(function (d) {
|
|
78
|
-
if (!d || d.enabled === false) { clearCards(); schedule(60000); return; }
|
|
79
|
-
take(
|
|
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 : []);
|
|
80
151
|
var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
|
|
81
|
-
|
|
82
|
-
|
|
152
|
+
schedule(nextWait(every));
|
|
153
|
+
landed();
|
|
83
154
|
});
|
|
84
|
-
}, function () { schedule(DEFAULT_POLL_MS); });
|
|
85
|
-
});
|
|
155
|
+
}, function () { schedule(DEFAULT_POLL_MS); landed(); });
|
|
156
|
+
}).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
|
|
157
|
+
}
|
|
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);
|
|
86
165
|
}
|
|
87
|
-
|
|
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(); }
|
|
88
168
|
function stop() { clearTimeout(Q.timer); }
|
|
89
169
|
|
|
90
170
|
// ── state from a poll ─────────────────────────────────
|
|
91
|
-
function take(items) {
|
|
171
|
+
function take(items, settled) {
|
|
92
172
|
var pending = items.filter(function (i) { return i && i.answerable !== false && (i.kind === 'question' || i.kind === 'approval'); });
|
|
93
|
-
var
|
|
94
|
-
var
|
|
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]; });
|
|
95
190
|
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]; } });
|
|
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]; } });
|
|
97
192
|
pending.forEach(function (i) {
|
|
98
193
|
if (!Q.picks[i.id]) Q.picks[i.id] = (i.questions || []).map(function () { return { labels: [], other: '' }; });
|
|
99
194
|
});
|
|
100
195
|
Q.items = pending;
|
|
101
196
|
// 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();
|
|
197
|
+
if (before !== after) { render(); changed(); }
|
|
103
198
|
}
|
|
104
|
-
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) {} } }
|
|
105
202
|
|
|
106
203
|
// ── drawing ───────────────────────────────────────────
|
|
107
204
|
function complete(item) {
|
|
@@ -116,30 +213,91 @@
|
|
|
116
213
|
var text = Q.notes[item.id] || '';
|
|
117
214
|
return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
|
|
118
215
|
}
|
|
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
|
+
}
|
|
119
269
|
function questionCard(item, scope) {
|
|
120
270
|
var picks = Q.picks[item.id] || [];
|
|
121
271
|
var sending = !!Q.sending[item.id];
|
|
272
|
+
var single = (item.questions || []).length < 2;
|
|
122
273
|
var blocks = (item.questions || []).map(function (q, qi) {
|
|
123
274
|
var p = picks[qi] || { labels: [], other: '' };
|
|
124
275
|
var type = q.multiSelect ? 'checkbox' : 'radio';
|
|
125
276
|
var name = (scope || 'sq') + '-' + item.id + '-' + qi;
|
|
126
|
-
return '<fieldset class="sq-q"
|
|
127
|
-
|
|
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>' +
|
|
128
280
|
'<div class="sq-options">' + q.options.map(function (o, oi) {
|
|
129
281
|
var on = p.labels.indexOf(o.label) >= 0;
|
|
130
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>' +
|
|
131
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>';
|
|
132
|
-
}).join('') +
|
|
133
|
-
'<
|
|
134
|
-
|
|
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>';
|
|
135
289
|
}).join('');
|
|
136
|
-
var armed = Q.armed[item.id] === 'send';
|
|
137
290
|
return '<article class="sq-card" data-sq-card="' + esc(item.id) + '" data-kind="question">' +
|
|
138
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>' +
|
|
139
|
-
blocks +
|
|
140
|
-
'<
|
|
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>' +
|
|
141
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>' +
|
|
142
|
-
|
|
300
|
+
noteHtml(item) + '</div></article>';
|
|
143
301
|
}
|
|
144
302
|
function approvalCard(item) {
|
|
145
303
|
var a = item.approval || { tool: item.tool, summary: '', detail: '' };
|
|
@@ -154,6 +312,35 @@
|
|
|
154
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>' +
|
|
155
313
|
noteHtml(item) + '</article>';
|
|
156
314
|
}
|
|
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; }
|
|
157
344
|
// Other places can show the cards for one session (Today's overlay, panes/today-actions.js): the same items, picks,
|
|
158
345
|
// answer ids and posts, drawn into their own element. A mount is dropped when its element leaves the page.
|
|
159
346
|
var mounts = [];
|
|
@@ -168,14 +355,14 @@
|
|
|
168
355
|
function drawInto(m) {
|
|
169
356
|
var active = document.activeElement;
|
|
170
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;
|
|
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;
|
|
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;
|
|
174
361
|
if (focus) {
|
|
175
362
|
var box = m.el.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
|
|
176
363
|
if (box) { box.focus({ preventScroll: true }); try { box.setSelectionRange(focus.start, focus.end); } catch (_e) {} }
|
|
177
364
|
}
|
|
178
|
-
if (m.onChange) { try { m.onChange(list.length); } catch (_e) {} }
|
|
365
|
+
if (m.onChange) { try { m.onChange(list.length, mac.length); } catch (_e) {} }
|
|
179
366
|
}
|
|
180
367
|
function render() {
|
|
181
368
|
hostsOf().forEach(drawInto);
|
|
@@ -197,7 +384,16 @@
|
|
|
197
384
|
}
|
|
198
385
|
function patchOne(card, id, item) {
|
|
199
386
|
var send = card.querySelector('[data-sq="send"]');
|
|
200
|
-
if (send) { send.disabled = !!Q.sending[id]
|
|
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);
|
|
201
397
|
var allow = card.querySelector('[data-sq="allow"]'); if (allow) allow.textContent = Q.armed[id] === 'allow' ? 'Tap again to allow' : 'Allow once';
|
|
202
398
|
var deny = card.querySelector('[data-sq="deny"]'); if (deny) deny.textContent = Q.armed[id] === 'deny' ? 'Tap again to deny' : 'Deny';
|
|
203
399
|
var note = card.querySelector('[data-sq-note]'); if (note) { note.textContent = Q.notes[id] || ''; note.classList.toggle('hidden', !Q.notes[id]); }
|
|
@@ -209,26 +405,47 @@
|
|
|
209
405
|
handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
|
|
210
406
|
not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
|
|
211
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
|
+
}
|
|
212
423
|
function post(id, body, doneLine) {
|
|
213
424
|
Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
|
|
214
425
|
if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
|
|
215
426
|
body.clientAnswerId = Q.answerIds[id];
|
|
216
427
|
render();
|
|
428
|
+
var asked = itemOf(id);
|
|
217
429
|
api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
|
|
218
430
|
delete Q.sending[id];
|
|
431
|
+
noteAnswered(asked, id);
|
|
219
432
|
Q.items = Q.items.filter(function (i) { return i.id !== id; });
|
|
220
|
-
render();
|
|
433
|
+
render(); answeredTold();
|
|
221
434
|
W.toast(doneLine);
|
|
222
435
|
schedule(500);
|
|
223
436
|
}, function (err) {
|
|
224
437
|
delete Q.sending[id];
|
|
225
438
|
var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
|
|
226
439
|
if (settled) {
|
|
440
|
+
if (err.code === 'already_answered') noteAnswered(asked, id);
|
|
227
441
|
Q.items = Q.items.filter(function (i) { return i.id !== id; });
|
|
228
|
-
render();
|
|
442
|
+
render();
|
|
443
|
+
if (err.code === 'already_answered') answeredTold(); else changed();
|
|
444
|
+
W.toast(REFUSED[err.code]); schedule(500); return;
|
|
229
445
|
}
|
|
230
446
|
// 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] || (
|
|
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.');
|
|
232
449
|
render();
|
|
233
450
|
});
|
|
234
451
|
}
|
|
@@ -242,11 +459,51 @@
|
|
|
242
459
|
});
|
|
243
460
|
post(item.id, { answers: answers }, 'Sent to ' + engine(item) + '.');
|
|
244
461
|
}
|
|
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
|
+
}
|
|
245
487
|
function onClick(e) {
|
|
246
488
|
var b = e.target.closest('[data-sq]');
|
|
247
489
|
if (!b || b.disabled) return;
|
|
248
490
|
var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
|
|
249
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
|
+
}
|
|
250
507
|
if (what === 'leave') {
|
|
251
508
|
Q.armed[id] = '';
|
|
252
509
|
if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
|
|
@@ -255,14 +512,18 @@
|
|
|
255
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);
|
|
256
513
|
}, function (err) {
|
|
257
514
|
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();
|
|
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();
|
|
260
517
|
});
|
|
261
518
|
return;
|
|
262
519
|
}
|
|
263
520
|
// Tap, then tap again: the first tap only arms the button, and the second counts only once the first has been on
|
|
264
521
|
// 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) {
|
|
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
|
+
}
|
|
266
527
|
if (W.confirmReady && !W.confirmReady(Q.armedAt[id])) return;
|
|
267
528
|
if (what === 'send') return sendAnswers(item);
|
|
268
529
|
if (what === 'allow' || what === 'deny') return post(id, { decision: what }, what === 'allow' ? 'Allowed once.' : 'Denied.');
|
|
@@ -270,16 +531,52 @@
|
|
|
270
531
|
function onChange(e) {
|
|
271
532
|
var t = e.target;
|
|
272
533
|
if (!t.hasAttribute('data-sq-pick')) return;
|
|
273
|
-
var id = t.getAttribute('data-sq-id'),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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; });
|
|
277
544
|
else {
|
|
278
545
|
p.labels = [label]; p.other = '';
|
|
279
546
|
hostsOf().forEach(function (m) { var box = m.el.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; });
|
|
280
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.
|
|
281
552
|
Q.armed[id] = '';
|
|
553
|
+
if (Q.missing[id] != null) delete Q.missing[id];
|
|
282
554
|
patchCard(id);
|
|
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
|
+
}
|
|
283
580
|
}
|
|
284
581
|
function onInput(e) {
|
|
285
582
|
var t = e.target;
|
|
@@ -293,21 +590,41 @@
|
|
|
293
590
|
hostsOf().forEach(function (m) { m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; }); });
|
|
294
591
|
}
|
|
295
592
|
Q.armed[id] = '';
|
|
593
|
+
if (Q.missing[id] != null) delete Q.missing[id];
|
|
296
594
|
patchCard(id);
|
|
297
595
|
}
|
|
298
|
-
function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); }
|
|
596
|
+
function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); el.addEventListener('keydown', onKey); }
|
|
299
597
|
listen(host);
|
|
300
598
|
// Draw this session's cards into `el` and keep them in step; `onChange(count)` hears each redraw.
|
|
301
599
|
function mount(el, sessionId, onChange) {
|
|
302
600
|
if (!el) return null;
|
|
303
601
|
var m = { el: el, sessionId: String(sessionId || '').toLowerCase(), scope: 'sqm' + (mounts.length + 1), onChange: onChange || null };
|
|
304
602
|
mounts.push(m); listen(el); drawInto(m);
|
|
305
|
-
if (!Q.stopped)
|
|
603
|
+
if (!Q.stopped) nudge();
|
|
306
604
|
return { count: function () { return itemsFor(m.sessionId).length; }, unmount: function () { mounts = mounts.filter(function (x) { return x !== m; }); } };
|
|
307
605
|
}
|
|
308
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); }
|
|
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).
|
|
309
623
|
document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
|
|
310
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(); });
|
|
311
627
|
|
|
312
|
-
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 };
|
|
313
630
|
})();
|