@gotcos/glasses-server 7.0.7 → 7.0.18
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/.env.example +19 -0
- package/CHANGELOG.md +170 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
cap: null, // capabilities.sessionQuestions, once read
|
|
33
33
|
capAt: 0,
|
|
34
34
|
items: [], // pending items, oldest first
|
|
35
|
+
waiting: [], // X1 contract: the questions live sessions wait on (held, at_mac, answered)
|
|
36
|
+
webAnswer: null, // X1 final contract: what an answer from this page must carry right now (only with a hold switch on)
|
|
35
37
|
picks: {}, // id -> [{ labels: [], other: '' }] per question
|
|
36
38
|
armed: {}, // id -> 'send' | 'allow' | 'deny'
|
|
37
39
|
armedAt: {}, // id -> when it was armed (COSWeb.confirmReady)
|
|
@@ -59,7 +61,12 @@
|
|
|
59
61
|
// A nudge (a session that just went to waiting, Today coming on screen) never reads more often than this.
|
|
60
62
|
var NUDGE_GAP_MS = 1000;
|
|
61
63
|
// Miles's words for a question at the Mac (11/07), one place: Today's Needs you reads them from here too.
|
|
62
|
-
|
|
64
|
+
// W1 A5 (Miles 11/09, "there is no ability to answer the question"): said once, and honest that this page cannot answer it.
|
|
65
|
+
// Q1 M3 (X1 canary A1; lib/thread-turn-queue.ts:451): a message sent to a session held on its prompt is queued for the
|
|
66
|
+
// end of the turn. It does not answer the question, and nothing promises a way to answer it here.
|
|
67
|
+
var MAC_LINE = "Answer this at your Mac's prompt. A message sent from here does not answer it; it waits until the question is answered at the Mac.";
|
|
68
|
+
// A question that went straight to the Mac's prompt: no route carries its words (the hook keeps none), so say so.
|
|
69
|
+
var MAC_NO_WORDS = "COS cannot read this question's words from here. They show at the Mac's prompt.";
|
|
63
70
|
var ANSWERED_LINE = 'Answered. The session carries on.';
|
|
64
71
|
var ANSWERED_MS = 120000;
|
|
65
72
|
|
|
@@ -176,7 +183,7 @@
|
|
|
176
183
|
// the sheets still hear that the failure is over.
|
|
177
184
|
var was = Q.load;
|
|
178
185
|
Q.load = 'ok';
|
|
179
|
-
take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
|
|
186
|
+
take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : [], Array.isArray(d.waiting) ? d.waiting : null, d.webAnswer);
|
|
180
187
|
if (was !== 'ok') tellMounts();
|
|
181
188
|
var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
|
|
182
189
|
schedule(nextWait(every));
|
|
@@ -207,7 +214,28 @@
|
|
|
207
214
|
function stop() { clearTimeout(Q.timer); }
|
|
208
215
|
|
|
209
216
|
// ── state from a poll ─────────────────────────────────
|
|
210
|
-
|
|
217
|
+
// ── X1 contract v1 (COORD 2026-11-09 21:35): `waiting[]`, every AskUserQuestion a live Claude session waits on that the
|
|
218
|
+
// server saw asked: state held (a COS surface can answer it now), at_mac (only the Mac's own prompt can) or answered
|
|
219
|
+
// (kept 120 s). Words only in memory, as on the server. Keyed by `<sessionId>:<toolUseId>`, never by session alone.
|
|
220
|
+
var WAITING_STATES = { held: 1, at_mac: 1, answered: 1 };
|
|
221
|
+
function waitingEntry(w) {
|
|
222
|
+
if (!w || typeof w.key !== 'string' || !WAITING_STATES[w.state] || typeof w.sessionId !== 'string') return null;
|
|
223
|
+
var qs = (Array.isArray(w.questions) ? w.questions : []).slice(0, 4).map(function (q) {
|
|
224
|
+
return { question: String((q && q.question) || ''), header: String((q && q.header) || ''), multiSelect: !!(q && q.multiSelect),
|
|
225
|
+
options: (q && Array.isArray(q.options) ? q.options : []).slice(0, 8).map(function (o) { return { label: String((o && o.label) || ''), description: String((o && o.description) || '') }; }) };
|
|
226
|
+
}).filter(function (q) { return q.question; });
|
|
227
|
+
return { key: w.key, sessionId: w.sessionId.toLowerCase(), provider: 'claude', toolUseId: typeof w.toolUseId === 'string' ? w.toolUseId : null, state: w.state,
|
|
228
|
+
answerable: w.answerable === true && w.state === 'held', heldId: typeof w.heldId === 'string' && /^pq_[a-f0-9]{24}$/.test(w.heldId) ? w.heldId : null,
|
|
229
|
+
questions: qs, askedAt: Date.parse(w.askedAt) || 0, answeredAt: Date.parse(w.answeredAt) || 0 };
|
|
230
|
+
}
|
|
231
|
+
function take(items, settled, waiting, webAnswer) {
|
|
232
|
+
// An older server sends no `waiting`: keep the last list only for a moment's read, never invent one.
|
|
233
|
+
Q.waiting = Array.isArray(waiting) ? waiting.map(waitingEntry).filter(Boolean) : [];
|
|
234
|
+
// X1 final contract (`webAnswer`, sent with `waiting` while a hold switch is on): { touchRequired, touchMaxMs,
|
|
235
|
+
// confirmApprovals }. Absent: the page learns any rule from a refusal, as before.
|
|
236
|
+
Q.webAnswer = webAnswer && typeof webAnswer === 'object'
|
|
237
|
+
? { touchRequired: webAnswer.touchRequired === true, touchMaxMs: Number(webAnswer.touchMaxMs) > 0 ? Number(webAnswer.touchMaxMs) : 60000, confirmApprovals: webAnswer.confirmApprovals === true }
|
|
238
|
+
: null;
|
|
211
239
|
var pending = items.filter(function (i) { return i && i.answerable !== false && (i.kind === 'question' || i.kind === 'approval'); });
|
|
212
240
|
var atMac = (settled || []).filter(function (i) { return i && i.waitingAtMac === true && (i.kind === 'question' || i.kind === 'approval'); });
|
|
213
241
|
var before = Q.items.map(function (i) { return i.id; }).join('|') + '/' + Q.atMac.map(function (i) { return i.id; }).join('|');
|
|
@@ -249,9 +277,11 @@
|
|
|
249
277
|
return n > 0 && (q.multiSelect || n === 1);
|
|
250
278
|
});
|
|
251
279
|
}
|
|
280
|
+
// X1 contract: a question held for the whole wait (fullHold) is on the Mac's screen too. Both answers count; the first wins.
|
|
281
|
+
var FULL_HOLD_LINE = 'Answer here or at your Mac. The first answer counts.';
|
|
252
282
|
function noteHtml(item) {
|
|
253
283
|
var text = Q.notes[item.id] || '';
|
|
254
|
-
return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
|
|
284
|
+
return (item.fullHold === true ? '<p class="sq-fullhold">' + esc(FULL_HOLD_LINE) + '</p>' : '') + '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
|
|
255
285
|
}
|
|
256
286
|
// The card reads like Claude Code's own question in the terminal (Miles 11/07): a strip naming each question, ☒ once
|
|
257
287
|
// it has an answer, then ✓ Submit; each question in bold, numbered options with their descriptions under them, Other
|
|
@@ -357,11 +387,22 @@
|
|
|
357
387
|
// 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.
|
|
358
388
|
// No buttons: nothing here can deliver an answer to that prompt.
|
|
359
389
|
function macCard(item) {
|
|
390
|
+
if (item.fromWaiting) {
|
|
391
|
+
var body0 = (item.questions || []).map(function (q) {
|
|
392
|
+
return '<p class="sq-q-text sq-mac-q">' + esc(q.question) + '</p>' + (q.options.length ? '<ul class="sq-mac-opts">' + q.options.map(function (o) {
|
|
393
|
+
return '<li>' + esc(o.label) + (o.description ? '<span class="sq-mac-desc"> ' + esc(o.description) + '</span>' : '') + '</li>';
|
|
394
|
+
}).join('') + '</ul>' : '');
|
|
395
|
+
}).join('') || '<p class="sq-mac-nowords">' + esc(MAC_NO_WORDS) + '</p>';
|
|
396
|
+
return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="question">' +
|
|
397
|
+
'<header class="sq-head"><h2 class="sq-title">' + esc(engine(item)) + ' has a question</h2></header>' + body0 +
|
|
398
|
+
'<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
|
|
399
|
+
}
|
|
360
400
|
var words = Q.seen[item.id] || { questions: [], summary: '' };
|
|
361
401
|
var title = item.kind === 'approval' ? engine(item) + ' wants to use ' + (item.tool || 'a tool') : engine(item) + ' has a question';
|
|
362
402
|
var body = item.kind === 'approval'
|
|
363
403
|
? (words.summary ? '<pre class="sq-command">' + esc(words.summary) + '</pre>' : '')
|
|
364
404
|
: words.questions.map(function (q) { return '<p class="sq-q-text sq-mac-q">' + esc(q) + '</p>'; }).join('');
|
|
405
|
+
if (!body) body = '<p class="sq-mac-nowords">' + esc(MAC_NO_WORDS) + '</p>';
|
|
365
406
|
return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="' + esc(item.kind) + '">' +
|
|
366
407
|
'<header class="sq-head"><h2 class="sq-title">' + esc(title) + '</h2></header>' + body +
|
|
367
408
|
'<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
|
|
@@ -370,7 +411,24 @@
|
|
|
370
411
|
// Every item names its session (the broker validates it), so no session asked for matches none: the top of every
|
|
371
412
|
// pane never draws a Mac card.
|
|
372
413
|
var want = String(sessionId || '').toLowerCase();
|
|
373
|
-
|
|
414
|
+
// X1 contract: a question at the Mac's own prompt that this server saw asked has its words in `waiting`. One card per
|
|
415
|
+
// question, matched by tool_use_id: a held item that went back to the Mac and the waiting entry are the same question.
|
|
416
|
+
var waits = Q.waiting.filter(function (w) { return w.sessionId === want && w.state === 'at_mac'; });
|
|
417
|
+
var cards = waits.map(function (w) {
|
|
418
|
+
return { id: 'w:' + w.key, kind: 'question', sessionId: w.sessionId, provider: 'claude', toolUseId: w.toolUseId, fromWaiting: true, questions: w.questions };
|
|
419
|
+
});
|
|
420
|
+
var ids = {}; waits.forEach(function (w) { if (w.toolUseId) ids[w.toolUseId] = 1; });
|
|
421
|
+
var settled = Q.atMac.filter(function (i) {
|
|
422
|
+
if (String(i.sessionId || '').toLowerCase() !== want) return false;
|
|
423
|
+
if (i.toolUseId) return !ids[i.toolUseId];
|
|
424
|
+
// An older server gives a settled item no tool_use_id: show it only when no waiting entry speaks for the session.
|
|
425
|
+
return !waits.length;
|
|
426
|
+
});
|
|
427
|
+
return settled.concat(cards);
|
|
428
|
+
}
|
|
429
|
+
function waitingFor(sessionId) {
|
|
430
|
+
var want = String(sessionId || '').toLowerCase();
|
|
431
|
+
return Q.waiting.filter(function (w) { return w.sessionId === want; });
|
|
374
432
|
}
|
|
375
433
|
// True when this session's newest event here is an answer (from this page or any other screen) of the last two
|
|
376
434
|
// minutes, with nothing held for it now and nothing at the Mac since.
|
|
@@ -380,6 +438,12 @@
|
|
|
380
438
|
var at = 0, macAt = 0;
|
|
381
439
|
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); });
|
|
382
440
|
atMacFor(want).forEach(function (i) { macAt = Math.max(macAt, Date.parse(i.settledAt) || Date.parse(i.createdAt) || 0); });
|
|
441
|
+
// X1 contract: answered anywhere (the Mac's prompt or a COS surface) in the last 120 s, newer than any question still open.
|
|
442
|
+
Q.waiting.forEach(function (w) {
|
|
443
|
+
if (w.sessionId !== want) return;
|
|
444
|
+
if (w.state === 'answered' && Date.now() - w.answeredAt <= ANSWERED_MS) at = Math.max(at, w.answeredAt);
|
|
445
|
+
else if (w.state !== 'answered') macAt = Math.max(macAt, w.askedAt || Date.now());
|
|
446
|
+
});
|
|
383
447
|
return at > 0 && at >= macAt;
|
|
384
448
|
}
|
|
385
449
|
function wordsOf(id) { var w = Q.seen[id]; return w ? { questions: w.questions.slice(), summary: w.summary } : null; }
|
|
@@ -458,10 +522,32 @@
|
|
|
458
522
|
|
|
459
523
|
// ── answering ─────────────────────────────────────────
|
|
460
524
|
var REFUSED = {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
525
|
+
// Q1 (D4 card review M2, same server): already_answered is another COS device (a lens, the phone, Control or a browser);
|
|
526
|
+
// an answer at the Mac comes back as handed_to_desk with reason retracted (RETRACTED_LINE).
|
|
527
|
+
already_answered: 'Already answered on another device.', expired: 'This question timed out here. Answer it at the Mac.',
|
|
528
|
+
handed_to_desk: 'Left for the Mac. Answer it there.', hook_gone: 'This question is no longer waiting.',
|
|
529
|
+
not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.',
|
|
530
|
+
// X1 contract, web door with a full-hold flag on: none of these settle the card; the person taps again.
|
|
531
|
+
web_touch_required: 'Not sent: tap anywhere on this page, then answer again.',
|
|
532
|
+
// X1 final contract (Q1 O1): held, but not on your devices yet (the Mac has not been idle long enough). Not settled.
|
|
533
|
+
not_shown: 'Not sent: this question is not on your devices yet. Answer it at the Mac, or try again in a minute.',
|
|
534
|
+
confirm_required: 'Not sent: tap again to confirm.', confirm_invalid: 'Not sent: that confirmation ran out. Tap again to confirm.'
|
|
464
535
|
};
|
|
536
|
+
var RETRACTED_LINE = 'Already answered at the Mac, or the session moved on.';
|
|
537
|
+
// The page's api() reads a body's `reason` before its `error` (other panes rely on that), so a refusal such as
|
|
538
|
+
// {error: 'handed_to_desk', reason: 'retracted'} or {error: 'invalid_answer', reason: 'client_answer_id'} arrives with the
|
|
539
|
+
// reason as its code and the error word as its message. Put the server's error word back as the code, the reason beside it.
|
|
540
|
+
function refusal(err) {
|
|
541
|
+
if (!err || err.status == null) return err;
|
|
542
|
+
var word = typeof err.message === 'string' ? err.message : '';
|
|
543
|
+
if (!REFUSED[err.code] && REFUSED[word]) { err.reason = err.code; err.code = word; }
|
|
544
|
+
return err;
|
|
545
|
+
}
|
|
546
|
+
function refusedLine(err) {
|
|
547
|
+
if (err && err.code === 'handed_to_desk' && err.reason === 'retracted') return RETRACTED_LINE;
|
|
548
|
+
return err ? REFUSED[err.code] : '';
|
|
549
|
+
}
|
|
550
|
+
var KEEPS_CARD = { invalid_answer: 1, web_touch_required: 1, confirm_required: 1, confirm_invalid: 1, not_shown: 1 };
|
|
465
551
|
// QA round 1: an answer that never reached the server (no HTTP status: offline, a dropped link, a timeout) says so in
|
|
466
552
|
// plain words, with the button to tap; the retry goes under the same clientAnswerId, so the server replays it.
|
|
467
553
|
function unreachable(item, what) {
|
|
@@ -477,10 +563,37 @@
|
|
|
477
563
|
changed();
|
|
478
564
|
if (W.today && W.today.refresh) { try { W.today.refresh(); } catch (_e) {} }
|
|
479
565
|
}
|
|
566
|
+
// X1 contract: the web door sends how long ago this page was last touched (the tap that sent it counts). An older server
|
|
567
|
+
// or a flags-off server ignores the field.
|
|
568
|
+
function withTouch(body) {
|
|
569
|
+
var age = presenceAge();
|
|
570
|
+
if (age !== null) body.presenceAgeMs = age;
|
|
571
|
+
return body;
|
|
572
|
+
}
|
|
573
|
+
function confirmThen(id, body, doneLine) {
|
|
574
|
+
Q.sending[id] = 1; render();
|
|
575
|
+
var c = withTouch({ decision: body.decision });
|
|
576
|
+
api('/api/session-questions/' + encodeURIComponent(id) + '/confirm', { method: 'POST', body: c, timeoutMs: 20000 }).then(function (d) {
|
|
577
|
+
if (!d || typeof d.confirmId !== 'string' || !/^pc_[a-f0-9]{24}$/.test(d.confirmId)) throw Object.assign(new Error('confirm'), { status: 403, code: 'confirm_invalid' });
|
|
578
|
+
body.confirmId = d.confirmId;
|
|
579
|
+
post(id, body, doneLine);
|
|
580
|
+
}, function (err) {
|
|
581
|
+
delete Q.sending[id];
|
|
582
|
+
err = refusal(err);
|
|
583
|
+
var settles = err && REFUSED[err.code] && !KEEPS_CARD[err.code];
|
|
584
|
+
if (settles) { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); changed(); W.toast(refusedLine(err)); schedule(500); return; }
|
|
585
|
+
Q.notes[id] = refusedLine(err) || (err && err.status == null ? unreachable(itemOf(id), body.decision) : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
|
|
586
|
+
render();
|
|
587
|
+
});
|
|
588
|
+
}
|
|
480
589
|
function post(id, body, doneLine) {
|
|
590
|
+
// X1 final contract: when the server says approvals need a confirm, get it first rather than learn it from a refused
|
|
591
|
+
// answer. It still follows the person's second tap; a 403 confirm_required below stays the fallback.
|
|
592
|
+
if (body.decision && !body.confirmId && Q.webAnswer && Q.webAnswer.confirmApprovals) return confirmThen(id, body, doneLine);
|
|
481
593
|
Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
|
|
482
594
|
if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
|
|
483
595
|
body.clientAnswerId = Q.answerIds[id];
|
|
596
|
+
withTouch(body);
|
|
484
597
|
render();
|
|
485
598
|
var asked = itemOf(id);
|
|
486
599
|
api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
|
|
@@ -492,16 +605,22 @@
|
|
|
492
605
|
schedule(500);
|
|
493
606
|
}, function (err) {
|
|
494
607
|
delete Q.sending[id];
|
|
495
|
-
|
|
608
|
+
err = refusal(err);
|
|
609
|
+
// X1 contract: an approval the server wants confirmed gets its confirm (bound to this item and decision, 60 s, single
|
|
610
|
+
// use) and goes once more. The person already tapped twice; nothing is sent without a second tap.
|
|
611
|
+
if (err && err.status === 403 && err.code === 'confirm_required' && body.decision && !body.confirmId) {
|
|
612
|
+
return confirmThen(id, body, doneLine);
|
|
613
|
+
}
|
|
614
|
+
var settled = err && REFUSED[err.code] && !KEEPS_CARD[err.code];
|
|
496
615
|
if (settled) {
|
|
497
616
|
if (err.code === 'already_answered') noteAnswered(asked, id);
|
|
498
617
|
Q.items = Q.items.filter(function (i) { return i.id !== id; });
|
|
499
618
|
render();
|
|
500
619
|
if (err.code === 'already_answered') answeredTold(); else changed();
|
|
501
|
-
W.toast(
|
|
620
|
+
W.toast(refusedLine(err)); schedule(500); return;
|
|
502
621
|
}
|
|
503
622
|
// A timeout or a lost answer: the same clientAnswerId goes again on the next tap and the server replays it.
|
|
504
|
-
Q.notes[id] =
|
|
623
|
+
Q.notes[id] = refusedLine(err) || (err && err.status == null ? unreachable(asked, body.decision || 'send')
|
|
505
624
|
: 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
|
|
506
625
|
render();
|
|
507
626
|
});
|
|
@@ -566,11 +685,13 @@
|
|
|
566
685
|
Q.armed[id] = '';
|
|
567
686
|
if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
|
|
568
687
|
Q.sending[id] = 1; render();
|
|
569
|
-
api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: { clientAnswerId: Q.answerIds[id], handBack: true }, timeoutMs: 20000 }).then(function () {
|
|
688
|
+
api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: withTouch({ clientAnswerId: Q.answerIds[id], handBack: true }), timeoutMs: 20000 }).then(function () {
|
|
570
689
|
delete Q.sending[id]; Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast('Left for the Mac.'); schedule(500);
|
|
571
690
|
}, function (err) {
|
|
572
691
|
delete Q.sending[id];
|
|
573
|
-
|
|
692
|
+
err = refusal(err);
|
|
693
|
+
if (err && REFUSED[err.code] && !KEEPS_CARD[err.code]) { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); changed(); W.toast(refusedLine(err)); return; }
|
|
694
|
+
if (err && KEEPS_CARD[err.code]) { Q.notes[id] = refusedLine(err); render(); return; }
|
|
574
695
|
Q.notes[id] = err && err.status == null ? unreachable(item, 'leave') : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
|
|
575
696
|
});
|
|
576
697
|
return;
|
|
@@ -726,6 +847,6 @@
|
|
|
726
847
|
if (W.onRoute) W.onRoute(function (r) { if (r && r.pane === 'today') nudge(); });
|
|
727
848
|
|
|
728
849
|
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,
|
|
729
|
-
itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE,
|
|
850
|
+
itemsFor: itemsFor, atMacFor: atMacFor, waitingFor: waitingFor, answeredFor: answeredFor, wordsOf: wordsOf, fullHoldLine: FULL_HOLD_LINE, macLine: MAC_LINE, macNoWords: MAC_NO_WORDS, answeredLine: ANSWERED_LINE,
|
|
730
851
|
loadState: function () { return Q.load; }, retry: function () { nudge(); } };
|
|
731
852
|
})();
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
var CATEGORIES = [
|
|
17
17
|
{ id: 'general', title: 'General', summary: 'How COS Control opens, its shortcut, Today and the setup guide.', mac: ['App', 'Activity home', 'Setup guide'] },
|
|
18
18
|
{ id: 'ai', title: 'AI and accounts', summary: 'The AI apps COS works through, your keys and the models behind Work.', mac: ['Your AI apps', 'Local model', 'Your own key', 'Search'] },
|
|
19
|
-
{ id: 'glasses', title: 'Glasses and
|
|
19
|
+
{ id: 'glasses', title: 'Glasses, phone and web', summary: 'Pair your glasses and phone, and how videos reach this Mac.', mac: ['Devices', 'Video uploads'] },
|
|
20
20
|
{ id: 'meetings', title: 'Meetings', summary: 'Recording, the meeting assistant, live cues and your meeting library.', mac: ['Recording', 'Meeting assistant', 'Library and sync'] },
|
|
21
21
|
{ id: 'dictation', title: 'Dictation', summary: 'Type by voice into any Mac app: shortcuts, sounds, text and AI polish.', mac: ['Dictation', 'Microphone', 'Shortcuts', 'Sounds and audio', 'Text', 'AI polish', 'History'] },
|
|
22
22
|
{ id: 'voice', title: 'Transcription and voice', summary: 'The engines and models that turn speech into text on this Mac.', mac: ['Voice setup', 'Models', 'Performance'] },
|
|
@@ -162,6 +162,41 @@ html.ta-open #sq-host { display: none; }
|
|
|
162
162
|
.next-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 18px; padding: 0 16px 14px; }
|
|
163
163
|
.next-actions:empty { display: none; }
|
|
164
164
|
.next-actions .btn.small { min-height: 44px; }
|
|
165
|
+
/* Prep on every Next up row (Miles 11/09, app.js paintCalendarLater and nextPrepPanelHtml): a quiet Prep at the end of
|
|
166
|
+
each later row, and one small panel under the row it opens (the COS chat's field and buttons, laid flat in the card).
|
|
167
|
+
Spacing (Miles 14:15, from the 382 px render): every later row is one height, with or without Prep (the hold row
|
|
168
|
+
too), and its padding is inside that height, so the Prep link never makes a row taller. The open row and its panel
|
|
169
|
+
read as ONE block: no rules, the panel attached under its row, and the space under the block equals the space
|
|
170
|
+
between rows. The gap after Prep me, and the gap above "+N more today", are the same as between rows. One pitch per
|
|
171
|
+
width: --np-row is the row height, --np-gap the space the rows' own height leaves above and below a line of text,
|
|
172
|
+
--np-after the space under a button row (a button's box is 2 px taller than its line), measured equal to the row gap. */
|
|
173
|
+
#next-card { --np-row: 36px; --np-gap: 8px; --np-after: calc(var(--np-gap) + 2px); }
|
|
174
|
+
#next-card:has(.next-later:not(.hidden)) .next-actions { padding-bottom: var(--np-after); }
|
|
175
|
+
.next-later .later-row { align-items: center; min-height: var(--np-row); padding: 0; }
|
|
176
|
+
.next-later .later-what { flex: 1; min-width: 0; }
|
|
177
|
+
.next-later .later-prep { flex: none; min-height: var(--np-row); font-size: 14px; font-weight: 500; padding: 0 2px; }
|
|
178
|
+
.next-later .later-more { margin-top: 0; padding: 0; min-height: var(--np-row); }
|
|
179
|
+
.next-first-prep { padding: 0 16px var(--np-after); }
|
|
180
|
+
.next-first-prep.hidden { display: none; }
|
|
181
|
+
.next-prep { display: grid; gap: 8px; margin: 0 0 var(--np-after); padding: 2px 0 0; }
|
|
182
|
+
.next-first-prep .next-prep { margin: 0; padding: 0; }
|
|
183
|
+
.next-prep .field { width: 100%; min-height: 64px; max-height: 140px; font-size: 15px; resize: none; }
|
|
184
|
+
.next-prep-files { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
|
185
|
+
.next-prep-files li { display: flex; align-items: center; gap: 10px; min-width: 0; font-size: 14px; }
|
|
186
|
+
.next-prep-file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--sub); }
|
|
187
|
+
.next-prep-files .link-btn { min-height: 32px; font-size: 14px; font-weight: 500; }
|
|
188
|
+
.next-prep-acts { display: flex; align-items: center; gap: 8px 14px; flex-wrap: wrap; }
|
|
189
|
+
.next-prep-gap { flex: 1; }
|
|
190
|
+
.next-prep-add { position: relative; cursor: pointer; }
|
|
191
|
+
.next-prep-add:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
192
|
+
.next-prep-acts .cx-btn { height: 40px; }
|
|
193
|
+
.next-prep-acts .link-btn { min-height: 40px; font-size: 14px; }
|
|
194
|
+
/* Phones and short touch screens: 44 px touch rows, on the same query as app.css's 44 px floor for .cx-btn and .field,
|
|
195
|
+
so the panel's buttons and the rows change height together. */
|
|
196
|
+
@media (max-width: 599px), (pointer: coarse) and (max-height: 720px) {
|
|
197
|
+
#next-card { --np-row: 44px; --np-gap: 12px; }
|
|
198
|
+
.next-prep-files .link-btn, .next-prep-acts .link-btn { min-height: 44px; }
|
|
199
|
+
}
|
|
165
200
|
/* The Ask sheet's File to Work inbox result, on its own row (app.js fileNoteHtml). */
|
|
166
201
|
.nav-row .note.file-error { color: var(--hot); white-space: normal; }
|
|
167
202
|
/* Running card when the session list could not be read (app.js renderRunning). */
|
|
@@ -537,6 +537,8 @@
|
|
|
537
537
|
return open(pk === 'wait' ? 'wait' : 'run', id, O.opener, { from: { scroll: lb ? lb.scrollTop || 0 : 0 } });
|
|
538
538
|
}
|
|
539
539
|
if (what === 'send') return sendReply();
|
|
540
|
+
if (what === 'turnend') return sendChoice(Number(id));
|
|
541
|
+
if (what === 'approve-phone' && K.approvePhone && O.r) return K.approvePhone(phoneApprovalOptions(O.r));
|
|
540
542
|
if (what === 'att-remove' || what === 'att-retry') {
|
|
541
543
|
var fb = filesBox(); if (!fb) return;
|
|
542
544
|
O.note = { text: '', kind: '' }; paintNote();
|
|
@@ -772,7 +774,7 @@
|
|
|
772
774
|
}, function (err) {
|
|
773
775
|
row.undoing = false;
|
|
774
776
|
if (err && err.status === 404) O.loop = false;
|
|
775
|
-
row.undoNote = err && err.status === 404 ? 'Undo
|
|
777
|
+
row.undoNote = err && err.status === 404 ? 'Undo did not go through. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
|
|
776
778
|
if (row.state === 'dismissed') { row.until = Date.now() + UNDO_SECONDS * 1000; expireLater(); }
|
|
777
779
|
}).then(paintIntake);
|
|
778
780
|
}
|
|
@@ -829,7 +831,8 @@
|
|
|
829
831
|
paintFiles();
|
|
830
832
|
// The Mac's answer about files may not be in yet: ask once, then draw the clip.
|
|
831
833
|
var kit = KIT(), r0 = O.r;
|
|
832
|
-
|
|
834
|
+
paintPhoneOffer(r0);
|
|
835
|
+
if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) { paintFiles(); paintPhoneOffer(r0); } }, function () {});
|
|
833
836
|
// Files of a send a reload cut off go again with the same words; say so, since their chips are gone.
|
|
834
837
|
if (O.r.draft && O.r.draft.restored) {
|
|
835
838
|
var nf = O.r.draft.ids.length;
|
|
@@ -911,6 +914,14 @@
|
|
|
911
914
|
'<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
|
|
912
915
|
}
|
|
913
916
|
|
|
917
|
+
// The same exact packet picker and handoff as Sessions. This does not upgrade an ordinary Send.
|
|
918
|
+
function phoneApprovalOptions(r) {
|
|
919
|
+
return { r: r, prefix: 'ta', live: function () { return O.r === r && !!O.open; },
|
|
920
|
+
status: function (text, kind) { if (O.r === r) { O.note = { text: text, kind: kind || '' }; paintNote(); } },
|
|
921
|
+
files: function () { return filesState(filesBox(r)); } };
|
|
922
|
+
}
|
|
923
|
+
function paintPhoneOffer(r) { if (K.phoneOffer) K.phoneOffer(r, phoneApprovalOptions(r)); }
|
|
924
|
+
|
|
914
925
|
// ── files with a reply or a note (the COS chat's attach kit) ──
|
|
915
926
|
// One box per session, kept while this tab is open, so closing and reopening the sheet keeps what was attached.
|
|
916
927
|
var FILES = {};
|
|
@@ -1020,12 +1031,14 @@
|
|
|
1020
1031
|
// A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page;
|
|
1021
1032
|
// when the Mac's own prompt has it, that, read-only; else (a reply is the answer) the reply box below. No live
|
|
1022
1033
|
// peek and no Stop: it is not at work (W4-r2).
|
|
1023
|
-
html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(O.wait ? O.wait.why : 'Waiting for you') + '</span></p>';
|
|
1024
1034
|
var where = waitWhere();
|
|
1035
|
+
html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(waitWhy(where)) + '</span></p>';
|
|
1025
1036
|
// W13 (QA week of 11/01-11/08): the words Today's row showed for it (its `ask`) stay here, read-only, when no card
|
|
1026
1037
|
// draws them: the Mac line and a failed read alike. Never twice: not when the line above already says them.
|
|
1027
1038
|
var ask = waitAsk();
|
|
1028
1039
|
if (ask && (where === 'mac' || where === 'failed') && !O.macCount) html += '<blockquote class="ta-quote ta-mac-ask">' + esc(ask) + '</blockquote>';
|
|
1040
|
+
// W1 A5: no words anywhere (a question that went straight to the Mac): say that, rather than a card with nothing in it.
|
|
1041
|
+
if (!ask && where === 'mac' && !O.macCount && !askInWhy()) html += '<p class="ta-sub ta-mac-nowords">' + esc(macNoWords()) + '</p>';
|
|
1029
1042
|
// The Mac card says it itself when there is one; say it here only when there is none.
|
|
1030
1043
|
if (where === 'mac' && !O.macCount) html += '<p class="ta-sub ta-mac-line">' + esc(macLine()) + '</p>';
|
|
1031
1044
|
// W13: the questions could not be read, so whether one waits here is unknown. Say so, never "at the Mac", and read again.
|
|
@@ -1033,6 +1046,7 @@
|
|
|
1033
1046
|
' <button type="button" class="link-btn" data-ta="q-retry">Retry</button></p>';
|
|
1034
1047
|
if (where === 'answered') html += '<p class="ta-sub ta-mac-line">' + esc((W.sessionQuestions && W.sessionQuestions.answeredLine) || 'Answered. The session carries on.') + '</p>';
|
|
1035
1048
|
if (where === 'reply') html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</p>';
|
|
1049
|
+
if (where === 'turnend') html += turnEndHtml();
|
|
1036
1050
|
} else if (O.row || O.r) {
|
|
1037
1051
|
// Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
|
|
1038
1052
|
html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : O.row.provider) + ' <span id="ta-run-state">' + esc(runWords()) + '</span></p>';
|
|
@@ -1047,7 +1061,7 @@
|
|
|
1047
1061
|
box.innerHTML = html;
|
|
1048
1062
|
// The reply box shows only where a reply can be the answer; what was typed stays in it, hidden.
|
|
1049
1063
|
var foot = $('ta-foot');
|
|
1050
|
-
if (foot && O.open.kind === 'wait') { var w = waitWhere(); foot.hidden = w === 'failed' ? !!(O.r && O.r.provider === 'claude') : w !== 'reply'; }
|
|
1064
|
+
if (foot && O.open.kind === 'wait') { var w = waitWhere(); foot.hidden = w === 'failed' ? !!(O.r && O.r.provider === 'claude') : w !== 'reply' && w !== 'turnend'; }
|
|
1051
1065
|
}
|
|
1052
1066
|
// Where a waiting session's question is answered: 'here' (its cards in this sheet), 'answered' (on some screen just
|
|
1053
1067
|
// now), 'mac' (a Claude session's own prompt, or a held question that went back to the Mac), or 'reply' (a Codex or
|
|
@@ -1056,6 +1070,8 @@
|
|
|
1056
1070
|
// reply box stays a Codex or Cursor one.
|
|
1057
1071
|
function waitWhere() {
|
|
1058
1072
|
if (O.qCount) return 'here';
|
|
1073
|
+
// 7.0.18: a turn that ended asking him is answered by a reply (a choice or his own words), so the reply box shows.
|
|
1074
|
+
if (O.wait && O.wait.where === 'turnend') return 'turnend';
|
|
1059
1075
|
if (O.r && W.sessionQuestions && W.sessionQuestions.answeredFor && W.sessionQuestions.answeredFor(O.r.id)) return 'answered';
|
|
1060
1076
|
if (!O.macCount && W.sessionQuestions && W.sessionQuestions.loadState && W.sessionQuestions.loadState() === 'failed') return 'failed';
|
|
1061
1077
|
if (O.macCount || (O.r && O.r.provider === 'claude') || (O.wait && O.wait.where === 'mac')) return 'mac';
|
|
@@ -1066,7 +1082,57 @@
|
|
|
1066
1082
|
var a = O.wait && O.wait.ask ? String(O.wait.ask).trim() : '';
|
|
1067
1083
|
return a && !(O.wait.why && String(O.wait.why).trim() === a) ? a : '';
|
|
1068
1084
|
}
|
|
1069
|
-
|
|
1085
|
+
// 7.0.18: a turn that ended asking him (X1 contract v1). The question as the row has it, a button per choice, and the
|
|
1086
|
+
// required honest line. A tap sends the contract's exact text through the reader's Continue (K.send); his own words
|
|
1087
|
+
// in the box below are sent the same way.
|
|
1088
|
+
var TURN_END_LINE = 'A choice or a reply here reaches the session as a message from COS, not typed in the session, and the session may ask you to confirm.';
|
|
1089
|
+
var TURN_END_PREFIX = 'Answer sent from COS Control Web through COS, not typed in this session: ';
|
|
1090
|
+
function turnEndAnswer(text) { var t = String(text || '').trim(); return TURN_END_PREFIX + (t.indexOf(TURN_END_PREFIX) === 0 ? t.slice(TURN_END_PREFIX.length).trim() : t); }
|
|
1091
|
+
function turnEndHtml() {
|
|
1092
|
+
var w = O.wait || {}, choices = Array.isArray(w.choices) ? w.choices : [], busy = !!(O.r && O.r.sending);
|
|
1093
|
+
return (w.ask ? '<blockquote class="ta-quote ta-te-ask">' + esc(w.ask) + '</blockquote>' : '') +
|
|
1094
|
+
(choices.length ? '<div class="ta-te-choices">' + choices.map(function (c, i) {
|
|
1095
|
+
return '<button type="button" class="cx-btn' + (i === 0 ? ' primary' : '') + '" data-ta="turnend" data-id="' + i + '"' + (busy ? ' disabled' : '') + '>' + esc(c) + '</button>';
|
|
1096
|
+
}).join('') + '</div>' : '') +
|
|
1097
|
+
'<p class="ta-sub ta-te-line">' + esc(TURN_END_LINE) + '</p>';
|
|
1098
|
+
}
|
|
1099
|
+
/** Still waiting on this answer? The latest session list decides; a reply from anywhere clears it on the server. */
|
|
1100
|
+
function turnEndStill() {
|
|
1101
|
+
var nm = T.needs ? T.needs() : null, key = O.wait && O.wait.key;
|
|
1102
|
+
return !!(nm && key && nm.rows.some(function (x) { return x.kind === 'session' && x.key === key && x.where === 'turnend'; }));
|
|
1103
|
+
}
|
|
1104
|
+
function sendChoice(i) {
|
|
1105
|
+
var r = O.r, w = O.wait; if (!r || !w || r.sending) return;
|
|
1106
|
+
var label = (Array.isArray(w.choices) ? w.choices : [])[i];
|
|
1107
|
+
if (!label) return;
|
|
1108
|
+
if (!turnEndStill()) { O.note = { text: 'Already answered on another screen, or the session moved on. Nothing was sent.', kind: '' }; paintNote(); return; }
|
|
1109
|
+
r.sending = true; setSending(true); paintMeta();
|
|
1110
|
+
var done = function () { r.sending = false; if (O.r === r) { setSending(false); paintMeta(); } };
|
|
1111
|
+
return K.send(r, turnEndAnswer(label), {}).then(function (how) {
|
|
1112
|
+
done();
|
|
1113
|
+
if (O.r !== r) return;
|
|
1114
|
+
if (how === 'sent') O.note = { text: 'Sent \u201c' + label + '\u201d. It reaches the session as a message from COS; it may ask you to confirm.', kind: '' };
|
|
1115
|
+
else if (how === 'changed') { r.refusal = null; r.sendAnyway = null; paintVerdict(); O.note = { text: 'The session moved on before this reached it. \u201c' + label + '\u201d was not sent.', kind: 'error' }; }
|
|
1116
|
+
else if (how === 'refused') O.note = { text: 'The session did not take \u201c' + label + '\u201d. Try again, or answer it on your Mac.', kind: 'error' };
|
|
1117
|
+
paintNote();
|
|
1118
|
+
}, function (err) {
|
|
1119
|
+
done();
|
|
1120
|
+
if (O.r !== r) return;
|
|
1121
|
+
O.note = { text: 'Not sent: ' + (err && err.name === 'AbortError' ? 'no answer from your Mac yet. It may still land; tapping again is safe, it will not post twice.' : (err && err.message) || 'could not reach your Mac.'), kind: 'error' };
|
|
1122
|
+
paintNote();
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "Answer this at your Mac's prompt. A message sent from here does not answer it; it waits until the question is answered at the Mac."; }
|
|
1126
|
+
function macNoWords() { return (W.sessionQuestions && W.sessionQuestions.macNoWords) || "COS cannot read this question's words from here. They show at the Mac's prompt."; }
|
|
1127
|
+
// W1 A5: the head line says the state once. At the Mac's prompt the line below says where; the head does not repeat it.
|
|
1128
|
+
var GENERIC_WAIT = /^Waiting for (you|your answer)(\. Paused until you answer\.)?$/;
|
|
1129
|
+
function waitWhy(where) {
|
|
1130
|
+
var why = O.wait ? String(O.wait.why || '') : '';
|
|
1131
|
+
if (!why) return 'Waiting for you';
|
|
1132
|
+
return where === 'mac' && GENERIC_WAIT.test(why) ? 'Waiting for your answer' : why;
|
|
1133
|
+
}
|
|
1134
|
+
// The head line already carries the question's words (the server named them in the waiting line).
|
|
1135
|
+
function askInWhy() { return !!(O.wait && O.wait.why && !GENERIC_WAIT.test(String(O.wait.why))); }
|
|
1070
1136
|
|
|
1071
1137
|
// ═════════════ The pet's sessions ═════════════
|
|
1072
1138
|
// The same sessions the pet's caption counts (app.js petPlay): waiting on you, running, then done today. Each row
|
|
@@ -1266,7 +1332,8 @@
|
|
|
1266
1332
|
var fb = sent.length ? filesBox(r) : null, kit = KIT();
|
|
1267
1333
|
if (fb && kit) fb.atts.filter(function (t) { return sent.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(fb, t.id); });
|
|
1268
1334
|
var box = r === O.r ? $('ta-input') : null;
|
|
1269
|
-
|
|
1335
|
+
// 7.0.18: his answer to a turn-end ask went with the attribution in front; the box holds his words without it.
|
|
1336
|
+
var left = box ? (box.value.trim() === String(text).trim() || (box.value.trim() && turnEndAnswer(box.value) === String(text).trim()) ? '' : box.value) : '';
|
|
1270
1337
|
if (box && !left) { box.value = ''; W.autoGrow(box); }
|
|
1271
1338
|
keepDraft(r, left, null);
|
|
1272
1339
|
},
|
|
@@ -1342,6 +1409,8 @@
|
|
|
1342
1409
|
if (r.refusal || O.cautionAsk) { r.refusal = null; O.cautionAsk = false; paintVerdict(); }
|
|
1343
1410
|
r.sending = true; setSending(true);
|
|
1344
1411
|
var done = function () { r.sending = false; if (O.r === r) setSending(false); };
|
|
1412
|
+
// 7.0.18: his own words in answer to a turn-end ask carry the same attribution as a choice ('Other' in the contract).
|
|
1413
|
+
if (O.open && O.open.kind === 'wait' && waitWhere() === 'turnend' && turnEndStill()) text = turnEndAnswer(text);
|
|
1345
1414
|
var sending = K.send(r, text, opts || {});
|
|
1346
1415
|
// continueSession picks the key at once (draftFor): keep it until the Mac takes the words.
|
|
1347
1416
|
if (r.draft) keepDraft(r, text, r.draft);
|
|
@@ -323,3 +323,6 @@
|
|
|
323
323
|
.wk-detail.split-side > .split-handle { display: flex; }
|
|
324
324
|
}
|
|
325
325
|
@media (min-width: 1300px) { .wk-detail.split-side { grid-template-columns: minmax(0, 1fr) 22px 452px; } }
|
|
326
|
+
|
|
327
|
+
/* W1 (0.5.386 parity): the empty card's one-tap New session button wraps on a folded phone instead of running off the card. */
|
|
328
|
+
.wk-start-new { white-space: normal; max-width: 100%; height: auto; min-height: 40px; text-align: center; line-height: 1.3; padding-top: 8px; padding-bottom: 8px; }
|