@gotcos/glasses-server 7.0.7 → 7.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. 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
- var MAC_LINE = "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens).";
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
- function take(items, settled) {
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
- return Q.atMac.filter(function (i) { return String(i.sessionId || '').toLowerCase() === want; });
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
- already_answered: 'Already answered, on the glasses or the Mac.', expired: 'Too late: the Mac took this back.',
462
- handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
463
- not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
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
- var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
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(REFUSED[err.code]); schedule(500); return;
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] = REFUSED[err && err.code] || (err && err.status == null ? unreachable(asked, body.decision || 'send')
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
- 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; }
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
  })();
@@ -492,3 +492,283 @@
492
492
  .u-breakdown summary::-webkit-details-marker { display: none; }
493
493
  .u-breakdown summary .u-chev { color: var(--accent); transform: rotate(-90deg); transition: transform 120ms ease; }
494
494
  .u-breakdown[open] summary .u-chev { transform: none; }
495
+
496
+ /* ── the frame (7.0.23, step 1 of the chat-style Sessions; DESIGN_PLAN v2 "The frame") ──────────────────────────────
497
+ While Sessions shows its list and reader (app.js sxFrameSync sets html.sx-frame), the page is one screen, never
498
+ taller: the app shell is the visible box the keyboard kit publishes (--vv-top, --vv-h from viewportFit, as kb-chat
499
+ uses them), so the document has nothing left to scroll. The rail, the list and the reader's body each scroll by
500
+ themselves; the reader's header stays at its top and the dock (activity with Stop, the Mac's question, the question
501
+ cards, the reply box, its status and the queue) stays at its bottom. Layout, not a proven fix for the 20:16 report:
502
+ app.js sxFrameCrumb reports where the parts sit against the visible area. In a Safari tab (not the Home Screen app)
503
+ a fixed shell ends above Safari's own bottom bar; the app has no such bar. */
504
+ html.sx-frame, html.sx-frame body { height: 100%; overflow: hidden; overscroll-behavior: none; }
505
+ html.sx-frame body { min-height: 0; }
506
+ html.sx-frame .app-shell {
507
+ position: fixed; left: 0; right: 0; top: var(--vv-top, 0px); height: var(--vv-h, 100dvh); min-height: 0;
508
+ display: flex; flex-direction: column; overflow: hidden;
509
+ }
510
+ html.sx-frame .main-col { flex: 1 1 auto; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
511
+ html.sx-frame .topbar, html.sx-frame .navbar { flex: none; position: relative; }
512
+ html.sx-frame .workspace {
513
+ flex: 1 1 auto; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden;
514
+ padding-bottom: calc(var(--safe-bottom) + 8px);
515
+ }
516
+ html.sx-frame #pane-sessions { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; width: 100%; }
517
+ /* What sits over every pane (the Mac not answering, the sign-in ending, a session's waiting question cards) keeps its
518
+ place above Sessions, but never takes the screen: past 35 percent it scrolls by itself. */
519
+ html.sx-frame #conn-notice, html.sx-frame #sq-host, html.sx-frame .workspace > :not(.pane) { flex: none; }
520
+ html.sx-frame #sq-host { max-height: 35%; overflow: auto; overscroll-behavior: contain; margin-bottom: 10px; }
521
+ /* In the frame a waiting question is its one line at every width (questions.css shows it so under 600 px): a tap opens
522
+ the cards. The open session's own cards sit in the reader's dock either way. */
523
+ html.sx-frame .sq-host .sq-banner { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 10px 14px; border-radius: 14px;
524
+ background: var(--panel); border: 1px solid color-mix(in srgb, var(--hot) 45%, var(--panel-border)); color: var(--ink); font: 600 15px/1.3 var(--body); text-align: left; }
525
+ html.sx-frame .sq-host .sq-banner-text { flex: 1; min-width: 0; }
526
+ html.sx-frame .sq-host .sq-banner-act { flex: none; color: var(--accent); }
527
+ html.sx-frame .sq-host .sq-cards:not(.open) { display: none; }
528
+ html.sx-frame #pane-sessions > .sx { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
529
+ html.sx-frame .sx-hero, html.sx-frame .sx-views { flex: none; }
530
+ /* In the frame the Sessions header is one line (the same title, sources line, counts and Refresh), so the list and the
531
+ reader keep the height; the view switch keeps its own row. */
532
+ html.sx-frame .sx-hero { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding-top: 0; padding-bottom: 8px; margin-bottom: 0; }
533
+ html.sx-frame .sx-hero .cx-glyph { display: none; }
534
+ html.sx-frame .sx-hero .cx-hero-text { flex: 1 1 260px; display: flex; align-items: baseline; gap: 12px; min-width: 0; }
535
+ html.sx-frame .sx-hero .cx-title { font-size: 22px; flex: none; }
536
+ html.sx-frame .sx-hero .cx-detail-line { margin: 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
537
+ html.sx-frame .sx-hero .cx-stats { order: 2; margin-top: 0; flex: none; }
538
+ html.sx-frame .sx-hero .cx-actions { order: 3; flex: none; }
539
+ html.sx-frame .sx-views { margin: 4px 0 8px; }
540
+ /* The reader's header in one row: the badge, the title on one line, Hide list; the subtitle is one line under it. */
541
+ html.sx-frame .sx-head-line { flex-wrap: nowrap; }
542
+ html.sx-frame .sx-reader-title { min-width: 0; flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 20px; }
543
+ html.sx-frame .sx-subtitle { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
544
+ html.sx-frame .sx-reader-head { padding-bottom: 8px; margin-bottom: 0; }
545
+ html.sx-frame .sx-split { flex: 1 1 auto; min-height: 0; margin-top: 0; }
546
+ html.sx-frame .sx-split > .split-list { min-height: 0; overflow: auto; overscroll-behavior: contain; }
547
+ html.sx-frame .sx-split > .sx-reader { min-height: 0; flex-direction: column; overflow: hidden; }
548
+ html.sx-frame .sx-reader-head { flex: none; }
549
+ /* The conversation between the header and the dock; it opens at the newest turn (app.js sxToNewest). */
550
+ .sx-body { min-height: 0; }
551
+ html.sx-frame .sx-body { flex: 1 1 0; overflow: auto; overscroll-behavior: contain; padding-bottom: 8px; }
552
+ /* The dock takes at most 72 percent of the reader (with the keyboard up, what the reader has below its head). 72, not 60:
553
+ at 951 by 539 with files, a queued note and the busy note, 60 left a 33 px window above the pinned reply box, too
554
+ short for a 44 px Cancel (dock_sweep). The reply box and its status always
555
+ show: what can grow above or below them (the activity, the queue) gives way or scrolls inside its own box. */
556
+ html.sx-frame .sx-dock { flex: 0 0 auto; max-height: 72%; min-height: 0; display: flex; flex-direction: column; position: relative;
557
+ padding-top: 8px; border-top: 1px solid var(--hairline); }
558
+ html.sx-frame .sx-dock > .sx-activity, html.sx-frame .sx-dock > .sx-mac-q { flex: none; }
559
+ /* What it is doing now keeps about four lines in the dock and scrolls past that, so it never pushes the reply box out. */
560
+ html.sx-frame .sx-dock > .sx-activity { flex: 0 4 auto; min-height: 32px; max-height: 96px; overflow: auto; }
561
+ /* The reply part never shrinks: a dock capped at 60 percent with overflow hidden would clip the reply box instead (951 by
562
+ 539, Go to the question shown: the box ran 17 px past the screen). What gives way is the activity; the queue inside the
563
+ reply part has its own cap and scrolls. */
564
+ html.sx-frame .sx-dock .sx-chat { margin-top: 0; flex: none; display: flex; flex-direction: column; }
565
+ html.sx-frame .sx-dock .sx-chat > * { flex: none; }
566
+ /* Go to the question sits on the dock's top edge, over the last line of the conversation, so it takes no room from the
567
+ dock at any height (a dock line squeezed its own 44 px button on short screens). */
568
+ html.sx-frame #session-reader > .sx-goq { position: absolute; right: 8px; left: auto; bottom: calc(var(--sx-dock-h, 120px) + 6px); margin: 0; z-index: 2;
569
+ padding: 0; border: 0; background: transparent; }
570
+ /* Only the button shows there, so it covers the end of one line, not the conversation; the sentence stays for screen
571
+ readers. */
572
+ html.sx-frame #session-reader > .sx-goq > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
573
+ html.sx-frame #session-reader > .sx-goq .cx-btn { background: var(--panel); border-color: color-mix(in srgb, var(--accent) 55%, var(--panel-border));
574
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); }
575
+ /* The session's question cards end the conversation (app.js openSession), so they scroll with it; their own footer
576
+ (Submit) sticks to the bottom of the conversation the way it sticks to the page elsewhere. */
577
+ html.sx-frame .sx-body #session-questions:not(:empty) { margin-top: 12px; }
578
+ /* An empty status line under the reply box takes no room in the frame (it keeps its 20 px when it says something). */
579
+ html.sx-frame .sx-dock #session-status:empty { min-height: 0; margin: 0; }
580
+ html.sx-frame .sx-dock #session-queue { flex: none; max-height: none; overflow: visible; }
581
+ /* With files in the reply box (7.0.23 regression, attach5_check): the chip row wrapped and grew (5 chips at 1119 wide
582
+ were 221 px), the reply form spilled past the reader's clipped bottom, and Send sat below the screen with nothing to
583
+ scroll (2 files on the open Duo; 1 file with the keyboard up at 449). The chip row now scrolls inside two rows (one
584
+ while typing), and the dock scrolls by itself as a last resort, so Send can always be reached. */
585
+ /* THE DOCK RULE (lead 08:47, after three overflows: the reply box at 951 by 539, the sign-in screen, Send under files):
586
+ the dock never exceeds the visible height (it is capped inside the reader, which is inside the visible box); its
587
+ inside scrolls; and the reply box, Send included, is always in view, pinned to the dock's bottom while what is above
588
+ it scrolls. dock_sweep.cjs checks every combination the dock can hold at five sizes, with and without the keyboard. */
589
+ html.sx-frame #session-reader { position: relative; }
590
+ html.sx-frame .sx-dock { overflow-y: auto; overscroll-behavior: contain; }
591
+ /* The reply part's own box is dissolved into the dock (display: contents) so the reply box can stick to the dock's
592
+ bottom: a sticky box cannot leave its parent, and .sx-chat also holds the queue. The reply box goes last; the
593
+ busy note, the status line and the queue sit above it and scroll with the dock. Its own rows are capped against the
594
+ visible height (--vv-h), so it always fits in the dock: the chip row, then the draft, each scroll inside. */
595
+ html.sx-frame .sx-dock .sx-chat { display: contents; }
596
+ html.sx-frame .sx-dock #session-form { order: 10; position: sticky; bottom: 0; z-index: 1; background: var(--page); }
597
+ html.sx-frame .sx-dock #session-input { max-height: min(140px, calc(var(--vv-h, 600px) * 0.1)); }
598
+ /* A swipe that reaches the end of the activity or the chip row carries on into the dock (no overscroll containment):
599
+ a swipe over the activity line used to stop there and never move the dock. */
600
+ html.sx-frame .sx-dock #session-files:not(.hidden) { max-height: max(52px, min(104px, calc(var(--vv-h, 600px) * 0.1))); overflow-y: auto; }
601
+ html.sx-frame.kb-up .sx-dock #session-files:not(.hidden) { max-height: 52px; }
602
+ html.sx-frame.kb-up .sx-dock #session-input { max-height: min(4.5em, calc(var(--vv-h, 600px) * 0.17)); }
603
+
604
+ /* Phone and narrow (under 760): one column at a time, the list or the reader, each filling the frame. */
605
+ @media (max-width: 759px) {
606
+ html.sx-frame .sx-split { display: flex; flex-direction: column; }
607
+ html.sx-frame .sx-split > .split-list { flex: 1 1 auto; }
608
+ html.sx-frame .sx-split.reading > .sx-reader { flex: 1 1 auto; display: flex; }
609
+ }
610
+ /* From 600: the rail is a column of the shell and scrolls by itself (its labels from 1100 wide and 721 tall, control.css). */
611
+ @media (min-width: 600px) {
612
+ html.sx-frame .app-shell { display: grid; grid-template-columns: 74px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
613
+ html.sx-frame .rail { position: static; top: auto; height: auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
614
+ }
615
+ @media (min-width: 1100px) and (min-height: 721px) {
616
+ html.sx-frame .app-shell { grid-template-columns: 248px minmax(0, 1fr); }
617
+ }
618
+ /* From 760: the list, the divider and the reader side by side, each the full height of the split. */
619
+ @media (min-width: 760px) {
620
+ html.sx-frame .split.sx-split { grid-template-rows: minmax(0, 1fr); align-items: stretch; }
621
+ html.sx-frame .sx-split > .split-list { position: static; top: auto; max-height: none; height: 100%; }
622
+ /* relative, not static: the divider's 44 px touch area (::before) is placed against it and must stay its own size. */
623
+ html.sx-frame .sx-handle { position: relative; top: auto; height: auto; align-self: stretch; }
624
+ html.sx-frame .sx-split > .sx-reader { display: flex; height: 100%; }
625
+ }
626
+ /* An on-screen keyboard up (app.js html.kb-up): the header goes to one line and the conversation keeps the rest. */
627
+ html.sx-frame.kb-up .sx-hero, html.sx-frame.kb-up .sx-views, html.sx-frame.kb-up .sx-subtitle,
628
+ html.sx-frame.kb-up .navbar, html.sx-frame.kb-up #conn-notice, html.sx-frame.kb-up #sq-host { display: none; }
629
+ /* Short screens (under 600 tall, Comet's 951 x 539): the subtitle and counts step aside. */
630
+ @media (max-height: 599px) {
631
+ /* The notices over the pane (the Mac not answering, the sign-in ending) are one line each: the same words, cut with
632
+ an ellipsis, their buttons kept. */
633
+ html.sx-frame .conn-notice { margin-bottom: 8px; gap: 6px; grid-template-columns: minmax(0, 1fr); }
634
+ html.sx-frame .conn-line { flex-wrap: nowrap; min-width: 0; padding: 6px 10px 6px 14px; }
635
+ html.sx-frame .conn-line > .btn { flex: none; }
636
+ html.sx-frame .conn-line > :not(.btn):not(button) { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
637
+ html.sx-frame .conn-line .conn-sub { flex: 1 1 0; }
638
+ html.sx-frame #sq-host { margin-bottom: 8px; }
639
+ html.sx-frame .sq-host .sq-banner { min-height: 44px; padding: 6px 14px; }
640
+ html.sx-frame .sx-views { margin: 2px 0 6px; }
641
+ /* The Sessions header (title and Refresh) and the view switch share one row. */
642
+ html.sx-frame #pane-sessions > .sx { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); column-gap: 18px; }
643
+ html.sx-frame .sx-hero { grid-column: 1; grid-row: 1; flex-wrap: nowrap; padding-bottom: 6px; border-bottom: 0; background: none; margin: 0; padding-left: 0; padding-right: 0; }
644
+ html.sx-frame .sx-hero .cx-hero-text { flex: none; }
645
+ html.sx-frame .sx-views { grid-column: 2; grid-row: 1; align-self: center; margin: 0 0 6px; }
646
+ html.sx-frame .sx-split, html.sx-frame .sx-usage { grid-column: 1 / -1; grid-row: 2; }
647
+ html.sx-frame .sx-subtitle, html.sx-frame .sx-hero .cx-stats, html.sx-frame .sx-hero .cx-detail-line { display: none; }
648
+ }
649
+ html.sx-frame.kb-up .sx-reader-head { padding: 2px 0 6px; margin-bottom: 4px; }
650
+ html.sx-frame.kb-up .sx-reader-title { font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
651
+ /* While typing the reply area wins: the dock takes what it needs up to the whole reader below its head, and the
652
+ conversation gives way (it can reach 0 on a very short screen: 382 by 557 with the keyboard up). */
653
+ html.sx-frame.kb-up .sx-dock { max-height: none; flex: 0 1 auto; }
654
+ html.sx-frame.kb-up #session-input { max-height: 4.5em; }
655
+ /* While typing, the dock drops the spacing around the reply box: at 382 by 557 with the keyboard up (267 px visible) the
656
+ dock needed 187 px inside its 155 and the reply box ran 18 px past the screen (MX's step 6 found the same in Messages). */
657
+ html.sx-frame.kb-up .sx-dock { padding-top: 4px; }
658
+ html.sx-frame.kb-up .sx-dock > .sx-activity { margin-bottom: 4px; }
659
+ html.sx-frame.kb-up .sx-dock .sx-chat { padding-top: 0; }
660
+ html.sx-frame.kb-up .sx-dock #session-form { margin-top: 0; }
661
+ /* Touch at any size in the frame: app.css floors controls to 44 px only under 600 wide or 720 tall, so on the open Duo
662
+ (1119 by 787 at 0.85) Copy session was 32 px and the labelled rail's Settings 28 px (MX's step 6 found the same gap). */
663
+ @media (any-pointer: coarse) {
664
+ html.sx-frame .sx-foot .cx-btn, html.sx-frame .rail-settings { min-height: 44px; }
665
+ }
666
+
667
+ /* ── the list (7.0.23, step 2b-1; DESIGN_PLAN v2 "The list", the 23:33 mock) ─────────────────────────────────────────
668
+ The list's own head (the Sessions menu, Search, one quiet line, the sort menu), then groups with plain headings and
669
+ rows divided by hairlines (no boxed cards). A row's state is on its left edge and always in words too; the open row
670
+ has the panel background and a 1 px outline, so selection never rests on colour alone. */
671
+ .sx-lhead { display: grid; gap: 8px; padding: 2px 2px 8px; border-bottom: 1px solid var(--hairline); position: sticky; top: 0; z-index: 3; background: var(--page); }
672
+ /* Under 600 px the top bar already names Sessions; the view switch (Sessions, Usage, COS cost) stays for the other two. */
673
+ @media (max-width: 599px) { .sx-lhead .sx-lh-top { display: none; } }
674
+ @media (min-width: 600px) { .sx-views.sx-wide-hide { display: none; } }
675
+ .sx-lh-top { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
676
+ .sx-view-btn { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; font: 600 18px/1.1 var(--body); color: var(--ink); }
677
+ .sx-view-btn .caret { width: 7px; height: 7px; border-right: 2px solid var(--sub); border-bottom: 2px solid var(--sub); transform: rotate(45deg) translateY(-2px); }
678
+ .sx-menu { position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; min-width: 200px; display: grid; padding: 6px; border-radius: 12px;
679
+ background: var(--panel); border: 1px solid var(--panel-border); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); }
680
+ .sx-menu.hidden { display: none; }
681
+ .sx-menu [role='menuitem'] { min-height: 40px; padding: 0 12px; border-radius: 8px; text-align: left; font: 500 15px/1.2 var(--body); color: var(--ink); }
682
+ .sx-menu [role='menuitem']:hover, .sx-menu [role='menuitem']:focus-visible { background: var(--qopen); }
683
+ .sx-lhead .sx-tools { margin: 0; }
684
+ .sx-tools-line { display: flex; align-items: center; gap: 8px; }
685
+ .sx-quiet { margin: 0; flex: 1 1 auto; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--sub); }
686
+ .sx-tools-line .sx-select { margin-left: auto; }
687
+
688
+ .sx-groups { padding-bottom: 8px; }
689
+ .sx-gh { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; width: 100%; margin: 0; padding: 14px 12px 6px; font: 600 13px/1.3 var(--body); color: var(--sub); }
690
+ .sx-g:first-child .sx-gh { padding-top: 10px; }
691
+ .sx-gh .n { font: 500 12.5px/1.3 var(--body); color: var(--tertiary); font-variant-numeric: tabular-nums; }
692
+ .sx-gh.state { color: var(--ink); }
693
+ .sx-gh.needs .n { color: var(--accent); }
694
+ .sx-gh.working .n { color: var(--run-green); }
695
+ button.sx-gh { align-items: center; text-align: left; min-height: 36px; }
696
+ button.sx-gh:hover { color: var(--ink); }
697
+ .sx-gh .fold { display: inline-flex; align-items: center; gap: 8px; }
698
+ .sx-gh .chev { width: 7px; height: 7px; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(45deg) translate(-2px, -1px); }
699
+ button.sx-gh[aria-expanded='false'] .chev { transform: rotate(-45deg); }
700
+ .sx-rows { display: grid; }
701
+ .sx-r { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); border-radius: 10px; }
702
+ .sx-rows > .sx-r + .sx-r::before { content: ''; position: absolute; top: 0; left: 12px; right: 0; height: 1px; background: var(--hairline); }
703
+ .sx-r:hover { background: color-mix(in srgb, var(--panel) 55%, transparent); }
704
+ .sx-r[aria-selected='true'] { background: var(--panel); box-shadow: inset 0 0 0 1px var(--btn-border); }
705
+ .sx-r[aria-selected='true']::before, .sx-r[aria-selected='true'] + .sx-r::before { background: transparent !important; }
706
+ .sx-r-edge { position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 2px; }
707
+ .sx-r.running .sx-r-edge { background: var(--run-green); }
708
+ .sx-r.waiting .sx-r-edge { background: var(--accent); }
709
+ .sx-r.failed .sx-r-edge { background: var(--hot); }
710
+ .sx-r-open { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; align-items: baseline; min-width: 0; min-height: 56px; padding: 9px 12px 9px 12px; text-align: left; color: inherit; width: 100%; }
711
+ .sx-r-open:focus-visible { outline-offset: -2px; }
712
+ .sx-r-title { font: 600 14px/1.3 var(--body); color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
713
+ .sx-r-time { font-size: 12px; line-height: 1.3; color: var(--sub); white-space: nowrap; font-variant-numeric: tabular-nums; }
714
+ .sx-r-sub { grid-column: 1 / -1; display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--sub); }
715
+ .sx-r-sub .sx-glyph { width: 18px; height: 18px; flex: none; }
716
+ .sx-r-sub .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
717
+ .sx-r-sub .t b.failed { color: var(--hot); font-weight: 600; }
718
+ .sx-r-snip { grid-column: 1 / -1; font-size: 12.5px; line-height: 1.4; color: var(--sub); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
719
+ .sx-tag { flex: none; display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: 5px; border: 1px solid var(--panel-border); font: 600 11px/1 var(--body); color: var(--ink); }
720
+ .sx-r.compact .sx-r-open { min-height: 40px; padding-top: 6px; padding-bottom: 6px; align-items: center; }
721
+ .sx-r.compact .sx-r-title { font-weight: 500; }
722
+ .sx-more-older { margin: 6px 12px 0; }
723
+ /* Touch: every target in the list reaches 44 px. */
724
+ @media (any-pointer: coarse) {
725
+ .sx-r-open { min-height: 58px; }
726
+ .sx-r.compact .sx-r-open { min-height: 44px; }
727
+ button.sx-gh, .sx-view-btn, .sx-menu [role='menuitem'] { min-height: 44px; }
728
+ .sx-lhead #sessions-q, .sx-lhead #sessions-sort { height: 44px; min-height: 44px; }
729
+ }
730
+
731
+ /* ── folders, COS pins and rename (7.0.23, step 2b-2) ── */
732
+ .sx-r { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
733
+ .sx-r-more { width: 32px; height: 32px; margin-right: 6px; border-radius: 8px; display: grid; place-items: center; color: var(--sub); font: 600 18px/1 var(--body); }
734
+ .sx-r-more:hover, .sx-r-more[aria-expanded='true'] { background: var(--qopen); color: var(--ink); }
735
+ .sx-r-orig { grid-column: 1 / -1; font-size: 12px; line-height: 1.3; color: var(--tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
736
+ .sx-gh-row { display: flex; align-items: center; }
737
+ .sx-gh-row .sx-gh { flex: 1 1 auto; }
738
+ .sx-empty-folder { margin: 2px 12px 8px; font-size: 13px; color: var(--sub); }
739
+ .sx-new-folder { flex: none; }
740
+ .sx-org-note { margin: 0; font-size: 13px; color: var(--hot); }
741
+
742
+ /* ── the list fits its column at every width (7.0.27; Miles 13:01 on 7.0.26, the open Duo) ──────────────────────────
743
+ THE RULE: at every list width from the divider's minimum (240 px) to its maximum the list's content fits the column, so
744
+ the column never scrolls sideways and nothing in it is cut. The head is one grid column that grows to its widest
745
+ item's min-content width; the reach line, New folder and the sort menu on one unwrapped row measured 272 px at the
746
+ minimum, so the head, the search field with it, and the column were 32 px too wide and the column panned on x. Now
747
+ the reach line has its own full-width row and New folder and the sort menu share the next one, wrapping under each
748
+ other if they must. A folder heading may shrink below its name, so a long folder name never widens the list, and
749
+ it ends in an ellipsis like a title (rows already could). Nothing is hidden to make it fit: width_sweep.cjs
750
+ drags the real divider from its minimum to its maximum and checks the fit (each rule here is killed there). */
751
+ .sx-tools-line { flex-wrap: wrap; row-gap: 6px; }
752
+ .sx-tools-line .sx-quiet { flex: 1 0 100%; }
753
+ .sx-gh-row .sx-gh, .sx-gh .fold { min-width: 0; }
754
+ .sx-gh .fold .nm { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
755
+ .sx-org-note.hidden { display: none; }
756
+ .sx-inline { display: grid; gap: 8px; padding: 10px 12px 12px; margin: 6px 0; border-radius: 10px; background: var(--qopen); }
757
+ .sx-inline-label { font: 600 13px/1.3 var(--body); color: var(--ink); }
758
+ .sx-inline-hint { margin: 0; font-size: 12.5px; color: var(--sub); }
759
+ .sx-inline-acts { display: flex; flex-wrap: wrap; gap: 8px; }
760
+ .sx-pop { position: fixed; z-index: 60; max-width: min(320px, calc(100vw - 16px)); }
761
+ .sx-pop .sx-pop-note { margin: 0; padding: 8px 12px; font-size: 13px; color: var(--sub); }
762
+ .sx-pop [role='menuitemradio'] { min-height: 40px; padding: 0 12px; border-radius: 8px; text-align: left; font: 500 15px/1.2 var(--body); color: var(--ink); }
763
+ .sx-pop [role='menuitemradio'][aria-checked='true']::after { content: ' (here)'; color: var(--sub); }
764
+ .sx-pop [role='menuitemradio']:hover, .sx-pop [role='menuitemradio']:focus-visible { background: var(--qopen); }
765
+ @media (any-pointer: coarse) {
766
+ .sx-r-more { width: 44px; height: 44px; }
767
+ .sx-pop [role='menuitemradio'], .sx-pop [role='menuitem'], .sx-new-folder, .sx-inline .cx-btn { min-height: 44px; }
768
+ }
769
+
770
+ /* Q1 (23:40): a question waits at the end of the conversation, out of view. */
771
+ .sx-goq { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 8px; padding: 6px 6px 6px 12px; border-radius: 10px;
772
+ border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--panel-border)); background: var(--panel); font-size: 14px; color: var(--ink); }
773
+ .sx-goq[hidden] { display: none; }
774
+ @media (any-pointer: coarse) { .sx-goq .cx-btn { min-height: 44px; } }
@@ -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 iPhone', summary: 'Pair your glasses and phone, and how videos reach this Mac.', mac: ['Devices', 'Video uploads'] },
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). */