@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.
- package/CHANGELOG.md +224 -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 +42 -9
- package/server/web-control/app.js +975 -114
- package/server/web-control/control.css +12 -6
- package/server/web-control/index.html +6 -1
- package/server/web-control/panes/cos-chat.css +27 -3
- package/server/web-control/panes/cos-chat.js +240 -46
- package/server/web-control/panes/meetings.css +5 -2
- package/server/web-control/panes/meetings.js +21 -10
- package/server/web-control/panes/memories.css +1 -1
- package/server/web-control/panes/memories.js +3 -8
- package/server/web-control/panes/questions.css +56 -2
- package/server/web-control/panes/questions.js +454 -56
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +20 -7
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +81 -3
- package/server/web-control/panes/today-actions.js +527 -62
- package/server/web-control/panes/work.css +1 -1
- 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
|
-
|
|
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,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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 () {
|
|
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
|
-
|
|
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
|
|
94
|
-
var
|
|
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"
|
|
127
|
-
|
|
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('') +
|
|
133
|
-
'<
|
|
134
|
-
|
|
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
|
-
'<
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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] ||
|
|
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();
|
|
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] || (
|
|
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) {
|
|
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'),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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)
|
|
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,
|
|
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
|
})();
|