@gotcos/glasses-server 6.99.33 → 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.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -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'] },
@@ -91,9 +91,10 @@
91
91
  Promise.all([
92
92
  soft(api('/api/decision-provider')), soft(api('/api/live-cues/v2/settings')), soft(api('/api/morning-brief')),
93
93
  soft(api('/api/domains')), soft(api('/api/agent-sessions/fences')), soft(api('/api/meeting-import/fireflies/status')),
94
- soft(api('/api/health')), soft(api('/api/drops/settings/p2')), soft(api('/api/today/brief/settings'))
94
+ soft(api('/api/health')), soft(api('/api/drops/settings/p2')), soft(api('/api/today/brief/settings')),
95
+ soft(api('/api/session-files/settings')), soft(api('/api/preview-folders/settings'))
95
96
  ]).then(function (r) {
96
- S.data = { dp: r[0], cues: r[1], mb: r[2], domains: r[3], fences: r[4], ff: r[5], health: r[6], p2: r[7], tb: r[8] };
97
+ S.data = { dp: r[0], cues: r[1], mb: r[2], domains: r[3], fences: r[4], ff: r[5], health: r[6], p2: r[7], tb: r[8], sf: r[9], pf: r[10] };
97
98
  S.state = 'ready';
98
99
  render();
99
100
  });
@@ -211,22 +212,68 @@
211
212
  if (!st) return '';
212
213
  var at = function (iso) { var d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); };
213
214
  if (st.kind === 'paused') return 'Paused after three failed tries. It tries again at ' + at(st.until) + '.';
214
- if (st.kind === 'daily_cap') return 'Today\u2019s ' + st.count + ' are used. It writes again tomorrow.';
215
+ // 7.0.5: the number is the "Summaries per day" setting (an older server sends only the count).
216
+ if (st.kind === 'daily_cap') { var n = typeof st.cap === 'number' ? st.cap : st.count; return 'Today\u2019s ' + n + (n === 1 ? ' summary is' : ' summaries are') + ' used. It writes again tomorrow.'; }
215
217
  if (st.kind === 'failed') return 'The last try failed at ' + at(st.at) + (st.code === 'timeout' ? ' (it took too long).' : st.code === 'unavailable' ? ' (Claude Code was not found on your Mac).' : '.');
216
218
  return '';
217
219
  }
220
+ // 6.99.35: "Show files and images your sessions make" (GET/POST /api/session-files/settings, lib/session-files-setting.ts).
221
+ // On by default. COS_WEB_SESSION_FILES=0 on the Mac forces it off; the row then says so and cannot be changed here.
222
+ function sessionFilesGroup(d) {
223
+ var sf = d && d.sf;
224
+ if (!sf || typeof sf.enabled !== 'boolean') return '';
225
+ var env = sf.source === 'env';
226
+ // QA 7.0.0: a saved setting the Mac could not read fails closed (off). The row says so; turning it on saves it again.
227
+ return group('Session files', '',
228
+ // 7.0.4: the switch also covers screenshots, images a session opened and images attached to prompts, so it says so.
229
+ row('Show session files, screenshots and images',
230
+ env ? 'Turned off on the Mac by the server environment (COS_WEB_SESSION_FILES=0).'
231
+ : sf.source === 'damaged' ? 'Couldn’t read the saved setting, so previews are off. Turn it on to save it again.'
232
+ : 'Lets this page show the files and images your sessions send you, the screenshots and images they open, and images attached to your prompts. Files stay on your Mac.',
233
+ toggle(sf.enabled, 'data-set="session-files"' + (env ? ' disabled' : ''), 'Show session files, screenshots and images')) +
234
+ previewFoldersRows(d.pf));
235
+ }
236
+ // 7.0.5 (preview engine, Miles decision 2b): extra folders a session's linked or sent files may open from, besides its
237
+ // own project folder (GET/POST /api/preview-folders/settings). Empty by default; each folder is listed by name and
238
+ // removed one at a time. The server refuses broad folders (home, Documents, Library, /) and says why.
239
+ function previewFoldersRows(pf) {
240
+ if (!pf || !Array.isArray(pf.folders)) return '';
241
+ var list = pf.folders.slice(0, 20);
242
+ var blurb = pf.source === 'damaged' ? 'Couldn’t read the saved list, so no extra folder counts. Add a folder to save it again.'
243
+ : list.length ? 'Sessions can also open files from these folders.' : 'None. Sessions open files only from their own project folder.';
244
+ return '<div class="cx-set-row cx-set-folders"><div class="cx-set-text"><span class="cx-set-name">Extra folders</span><span class="cx-set-desc" id="pf-blurb">' + esc(blurb) + '</span></div></div>' +
245
+ list.map(function (f, i) {
246
+ return '<div class="cx-set-row cx-set-folder"><div class="cx-set-text"><span class="cx-set-name pf-name">' + esc(f) + '</span></div>' +
247
+ '<button type="button" class="cx-btn quiet" data-set="pf-remove" data-index="' + i + '" aria-label="' + esc('Remove ' + f) + '">Remove</button></div>';
248
+ }).join('') +
249
+ (list.length < 20 ? '<form class="cx-set-row pf-add" id="pf-add"><label class="cx-set-text" for="pf-input"><span class="cx-set-desc">Add a project folder, like ~/cos-worktrees. Not your whole home or Documents folder.</span></label>' +
250
+ '<span class="pf-add-line"><input id="pf-input" type="text" inputmode="url" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="~/cos-worktrees" aria-label="Folder to add">' +
251
+ '<button type="submit" class="cx-btn" data-set="pf-add">Add</button></span></form>' : '');
252
+ }
253
+ function savePreviewFolders(next, done) {
254
+ api('/api/preview-folders/settings', { method: 'POST', body: { folders: next } }).then(function (v) {
255
+ if (S.data && v && Array.isArray(v.folders)) S.data.pf = v;
256
+ W.toast('Saved');
257
+ render();
258
+ if (done) done(true);
259
+ }, function (err) { say((err && err.message) || 'That folder was not saved.', true); if (done) done(false); });
260
+ }
218
261
  function categoryBody(cat) {
219
262
  var d = S.data || {};
220
263
  var out = '';
221
264
  if (cat.id === 'reach') return reachBody();
222
265
  if (cat.id === 'pet') return petBody() + onMac(cat.mac);
223
- // The Today summary (routes/today-brief.ts): Haiku on the Mac writes Today's top lines. Off unless switched on here or
266
+ if (cat.id === 'privacy') out += sessionFilesGroup(d);
267
+ // The Today summary (routes/today-brief.ts): Sonnet on the Mac writes Today's top lines (7.0.1; Haiku before). Off unless switched on here or
224
268
  // by COS_TODAY_BRIEF_LLM on the Mac, which wins when set.
225
269
  if (cat.id === 'general' && d.tb && typeof d.tb.enabled === 'boolean') {
226
270
  out += group('Today summary', '',
227
271
  row('Write the Today summary with a model', d.tb.source === 'env' ? 'Set on the Mac by the server environment.' :
228
- 'Haiku writes two or three sentences after the count of what needs you. It reads what Today shows (session titles and states, each session\u2019s latest reply, the questions sessions ask you, what needs you) and, on your Mac, each session\u2019s sub-agents and the Work cards in progress. It runs only while Today is open, only when something changed, never during a recording, at most every 10 minutes and 60 times a day.',
272
+ 'Sonnet writes two or three sentences after the count of what needs you. It reads what Today shows (session titles and states, each session\u2019s latest reply, the questions sessions ask you, what needs you) and, on your Mac, each session\u2019s sub-agents and the Work cards in progress. It runs only while Today is open, only when something changed, never during a recording, at most every 10 minutes and no more than Summaries per day allows. Refresh on Today asks for a new one at once, at most every 30 seconds.',
229
273
  toggle(d.tb.enabled, 'data-set="today-brief"' + (d.tb.source === 'env' ? ' disabled' : ''), 'Write the Today summary with a model')) +
274
+ // 7.0.5 "Summaries per day" (GET/PUT /api/today/brief/settings/cap), 1 to 100, default 100. Shown when the server has it.
275
+ (typeof d.tb.dailyCap === 'number' ? row('Summaries per day', 'At most this many new summaries a day, Refresh included. 1 to 100.',
276
+ '<input type="number" class="cx-set-num" id="today-brief-cap" data-set="today-brief-cap" min="1" max="100" step="1" inputmode="numeric" value="' + esc(String(d.tb.dailyCap)) + '" aria-label="Summaries per day">') : '') +
230
277
  (briefStatus(d.tb) ? '<p class="cx-set-blurb" id="today-brief-status">' + esc(briefStatus(d.tb)) + '</p>' : ''));
231
278
  }
232
279
  if (cat.id === 'ai' && d.dp) {
@@ -346,6 +393,25 @@
346
393
  .then(function () { W.toast(!on ? 'Live cues on' : 'Live cues off'); }, function (err) { t.setAttribute('aria-checked', String(on)); say(err.message, true); });
347
394
  return;
348
395
  }
396
+ if (act === 'session-files') {
397
+ var son = t.getAttribute('aria-checked') === 'true';
398
+ t.setAttribute('aria-checked', String(!son));
399
+ api('/api/session-files/settings', { method: 'POST', body: { enabled: !son } })
400
+ .then(function (v) {
401
+ if (S.data && v) S.data.sf = v;
402
+ W.toast(!son ? 'Session files and images on' : 'Session files and images off');
403
+ render();
404
+ }, function (err) { t.setAttribute('aria-checked', String(son)); say(err.message, true); });
405
+ return;
406
+ }
407
+ if (act === 'pf-remove') {
408
+ var cur = (S.data && S.data.pf && S.data.pf.folders) || [], idx = Number(t.getAttribute('data-index'));
409
+ if (!(idx >= 0 && idx < cur.length)) return;
410
+ t.disabled = true;
411
+ savePreviewFolders(cur.filter(function (_f, i) { return i !== idx; }), function (ok) { if (!ok) t.disabled = false; });
412
+ return;
413
+ }
414
+ if (act === 'pf-add') return; // the form's submit handler saves
349
415
  if (act === 'today-brief') {
350
416
  var ton = t.getAttribute('aria-checked') === 'true';
351
417
  t.setAttribute('aria-checked', String(!ton));
@@ -380,6 +446,15 @@
380
446
  }
381
447
  });
382
448
 
449
+ pane.addEventListener('submit', function (e) {
450
+ if (!e.target || e.target.id !== 'pf-add') return;
451
+ e.preventDefault();
452
+ var input = $('pf-input'), v = input ? input.value.trim() : '';
453
+ if (!v) { say('Type a folder, like ~/cos-worktrees.', true); return; }
454
+ var cur = (S.data && S.data.pf && S.data.pf.folders) || [];
455
+ savePreviewFolders(cur.concat([v]));
456
+ });
457
+
383
458
  // A gallery picture that will not load shows the pet's name alone, never a broken image.
384
459
  pane.addEventListener('error', function (e) {
385
460
  var img = e.target;
@@ -391,6 +466,22 @@
391
466
 
392
467
  // Number, time, day and steer fields save when they change (a number is clamped by the server, then shown as saved).
393
468
  pane.addEventListener('change', function (e) {
469
+ // 7.0.5 "Summaries per day": the server clamps to 1 to 100 and answers what it saved; the field shows that.
470
+ if (e.target && e.target.id === 'today-brief-cap') {
471
+ // QA 7.0.5: an emptied field (or letters, which a number field reads as '') is Number('') = 0, which the server
472
+ // clamps to 1. So an empty, non-numeric or below-1 value sends nothing, keeps the saved number and says why.
473
+ var capField = e.target, capRaw = String(capField.value == null ? '' : capField.value).trim(), wanted = Number(capRaw);
474
+ if (capRaw === '' || !isFinite(wanted) || wanted < 1) {
475
+ if (S.data && S.data.tb && typeof S.data.tb.dailyCap === 'number') capField.value = String(S.data.tb.dailyCap);
476
+ say('Type a number from 1 to 100.', true);
477
+ return;
478
+ }
479
+ api('/api/today/brief/settings/cap', { method: 'PUT', body: { dailyCap: wanted } }).then(function (v) {
480
+ if (v && typeof v.dailyCap === 'number') { capField.value = String(v.dailyCap); if (S.data && S.data.tb) S.data.tb.dailyCap = v.dailyCap; }
481
+ W.toast('Saved');
482
+ }, function (err) { say((err && err.message) || 'That did not save.', true); });
483
+ return;
484
+ }
394
485
  var f = e.target && e.target.closest ? e.target.closest('[data-p2]') : null;
395
486
  if (!f || f.getAttribute('data-set') === 'p2' || !S.data || !S.data.p2) return;
396
487
  var path = f.getAttribute('data-p2'), raw = f.value;
@@ -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). */
@@ -185,3 +220,14 @@ html.ta-open #sq-host { display: none; }
185
220
  .ta-notsent:empty { display: none; }
186
221
  .ta-notsent { margin: 4px 0 8px; }
187
222
  .ta-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }
223
+ /* 7.0.1 live state: Refresh and the line beside it under Today's date (app.js todayRefresh, liveNoteText). The button
224
+ keeps the 44 px target of every link button; the line says Updating, Reconnecting, "As of 2 min ago" or the server's
225
+ summary limit, in the date's own size and colour. */
226
+ .today-live { display: flex; align-items: center; flex-wrap: wrap; gap: 0 12px; margin: 2px 0 0; min-height: 44px; }
227
+ .today-refresh { min-height: 44px; min-width: 44px; padding: 0 2px; font-size: 15px; }
228
+ .today-refresh:disabled { opacity: 0.55; cursor: default; }
229
+ .today-live-note { color: var(--sub); font-size: 14px; line-height: 1.3; }
230
+ .today-live-note:empty { display: none; }
231
+ .today-live-note.warn { color: var(--warn, var(--sub)); }
232
+ /* A working row's clock ticks every second: tabular figures so the line does not shimmer. */
233
+ .run-clock { font-variant-numeric: tabular-nums; }
@@ -194,7 +194,7 @@
194
194
  var KB_MIN_PX = 120;
195
195
  var KB_STRIP_MIN_PX = 24;
196
196
  var BODY_MIN_PX = 72; // about three lines of the conversation (15 px text at 1.5)
197
- var KB = { on: false, strip: false, tight: false, frame: 0, listening: false, width: 0, inset: 0 };
197
+ var KB = { on: false, strip: false, tight: false, frame: 0, listening: false, width: 0, inset: 0, typed: false };
198
198
  function tall(el) { return (el && el.offsetHeight) || 0; }
199
199
  // What the conversation gets once the compact header and the reply box have theirs.
200
200
  function bodyRoom(height) {
@@ -211,10 +211,13 @@
211
211
  return !!a.isContentEditable;
212
212
  }
213
213
  function viewport() { return W.viewportFit ? W.viewportFit() : null; }
214
+ // 7.0.2: `wide` keeps its 6.99.30 meaning, 600 px wide or more, so old and new reports compare. From 7.0.2 a short
215
+ // touch screen at that width counts as a phone (app.js isPhone, A8): that is shortTouch.
216
+ function crumbWide() { return window.matchMedia ? !!window.matchMedia('(min-width: 600px)').matches : !phone(); }
214
217
  function crumb(what, v, focus) {
215
218
  if (!W.layoutCrumb || !O.open || !v) return;
216
219
  W.layoutCrumb(what, { kind: O.open.kind, kb: KB.on, strip: KB.strip, tight: KB.tight, focus: !!focus, inset: v.inset, below: v.below,
217
- lh: v.layoutH, top: v.top, h: v.height, zoomed: v.zoomed, wide: !phone() });
220
+ lh: v.layoutH, top: v.top, h: v.height, zoomed: v.zoomed, wide: crumbWide(), shortTouch: crumbWide() && phone() });
218
221
  }
219
222
  function applyFit() {
220
223
  KB.frame = 0;
@@ -246,7 +249,11 @@
246
249
  // again would take the room back and flicker.
247
250
  if (on && !KB.tight) KB.tight = bodyRoom(v.height) < BODY_MIN_PX;
248
251
  layer.classList.toggle('ta-kb-tight', on && KB.tight);
249
- if (was !== KB.on + '|' + KB.strip + '|' + KB.tight) crumb('kb', v, focus);
252
+ // 7.0.2 (A1): the breadcrumb goes on every keyboard open and close in the sheet (a field typing with KB_MIN_PX or more
253
+ // covered), not only when a class flips: a keyboard that came while ta-kb was already on used to send nothing.
254
+ var typed = focus && v.inset >= KB_MIN_PX, opened = typed !== KB.typed;
255
+ KB.typed = typed;
256
+ if (opened || was !== KB.on + '|' + KB.strip + '|' + KB.tight) crumb('kb', v, focus);
250
257
  return v;
251
258
  }
252
259
  function revealFocused() {
@@ -274,7 +281,7 @@
274
281
  function unfit() {
275
282
  listenViewport(false);
276
283
  if (KB.frame && window.cancelAnimationFrame) window.cancelAnimationFrame(KB.frame);
277
- KB.frame = 0; KB.on = false; KB.strip = false; KB.tight = false; KB.width = 0; KB.inset = 0;
284
+ KB.frame = 0; KB.on = false; KB.strip = false; KB.tight = false; KB.width = 0; KB.inset = 0; KB.typed = false;
278
285
  if (!layer) return;
279
286
  layer.classList.remove('ta-kb'); layer.classList.remove('ta-kb-strip'); layer.classList.remove('ta-kb-tight');
280
287
  }
@@ -457,8 +464,12 @@
457
464
  })();
458
465
  });
459
466
  }
460
- window.addEventListener('popstate', function () {
467
+ // Back from an enlarged image (panes/reader-media.js pushes one entry over the sheet's) is the image's Back: the
468
+ // viewer closes and the sheet, its scroll and a half-typed note stay (11/08). The viewer says which pops are its own.
469
+ function imagePop(e) { return !!(W.readerMedia && typeof W.readerMedia.popIsMine === 'function' && W.readerMedia.popIsMine(e)); }
470
+ window.addEventListener('popstate', function (e) {
461
471
  if (O.pops.length) { var w = O.pops.shift(); clearTimeout(w.timer); O.ignorePops = O.pops.length; if (w.then) w.then(); return; }
472
+ if (imagePop(e)) return;
462
473
  // The phone's Back from a session opened off the pet's list goes back to that list, as Back to sessions does.
463
474
  if (O.open && O.back) { backToList(); return; }
464
475
  if (O.open) close('pop');
@@ -518,6 +529,7 @@
518
529
  if (what === 'open-card') return leaveTo('work', id ? [id] : []);
519
530
  if (what === 'open-session') return O.r ? leaveTo('sessions', [O.r.provider, O.r.id]) : null;
520
531
  if (what === 'open-sessions') return leaveTo('sessions', []);
532
+ if (what === 'q-retry') { if (W.sessionQuestions && W.sessionQuestions.retry) W.sessionQuestions.retry(); return; }
521
533
  // A row of the pet's list opens that session's own sheet in its place, with Back to sessions (and the phone's Back)
522
534
  // returning to the list where it was scrolled; X still closes the overlay.
523
535
  if (what === 'pet-open') {
@@ -525,6 +537,8 @@
525
537
  return open(pk === 'wait' ? 'wait' : 'run', id, O.opener, { from: { scroll: lb ? lb.scrollTop || 0 : 0 } });
526
538
  }
527
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));
528
542
  if (what === 'att-remove' || what === 'att-retry') {
529
543
  var fb = filesBox(); if (!fb) return;
530
544
  O.note = { text: '', kind: '' }; paintNote();
@@ -760,7 +774,7 @@
760
774
  }, function (err) {
761
775
  row.undoing = false;
762
776
  if (err && err.status === 404) O.loop = false;
763
- row.undoNote = err && err.status === 404 ? 'Undo needs a newer COS server. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
777
+ row.undoNote = err && err.status === 404 ? 'Undo did not go through. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
764
778
  if (row.state === 'dismissed') { row.until = Date.now() + UNDO_SECONDS * 1000; expireLater(); }
765
779
  }).then(paintIntake);
766
780
  }
@@ -817,7 +831,8 @@
817
831
  paintFiles();
818
832
  // The Mac's answer about files may not be in yet: ask once, then draw the clip.
819
833
  var kit = KIT(), r0 = O.r;
820
- if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) paintFiles(); }, function () {});
834
+ paintPhoneOffer(r0);
835
+ if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) { paintFiles(); paintPhoneOffer(r0); } }, function () {});
821
836
  // Files of a send a reload cut off go again with the same words; say so, since their chips are gone.
822
837
  if (O.r.draft && O.r.draft.restored) {
823
838
  var nf = O.r.draft.ids.length;
@@ -899,6 +914,14 @@
899
914
  '<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
900
915
  }
901
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
+
902
925
  // ── files with a reply or a note (the COS chat's attach kit) ──
903
926
  // One box per session, kept while this tab is open, so closing and reopening the sheet keeps what was attached.
904
927
  var FILES = {};
@@ -942,6 +965,8 @@
942
965
  el.classList.toggle('hidden', !box.atts.length);
943
966
  }
944
967
  paintClip(); paintSendButton();
968
+ // QA 7.0.0: the "Working right now" note says whether files go with the message.
969
+ if (O.r && O.r.verdict) paintVerdict();
945
970
  }
946
971
  // Send waits while a file uploads. A failed file never greys it out (QA 6.99.27): a tap says why, as the question
947
972
  // card does.
@@ -1006,12 +1031,22 @@
1006
1031
  // A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page;
1007
1032
  // when the Mac's own prompt has it, that, read-only; else (a reply is the answer) the reply box below. No live
1008
1033
  // peek and no Stop: it is not at work (W4-r2).
1009
- 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>';
1010
1034
  var where = waitWhere();
1035
+ html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(waitWhy(where)) + '</span></p>';
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
1037
+ // draws them: the Mac line and a failed read alike. Never twice: not when the line above already says them.
1038
+ var ask = waitAsk();
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>';
1011
1042
  // The Mac card says it itself when there is one; say it here only when there is none.
1012
1043
  if (where === 'mac' && !O.macCount) html += '<p class="ta-sub ta-mac-line">' + esc(macLine()) + '</p>';
1044
+ // W13: the questions could not be read, so whether one waits here is unknown. Say so, never "at the Mac", and read again.
1045
+ if (where === 'failed') html += '<p class="ta-note ta-q-failed" role="status">' + esc('Couldn\u2019t load this session\u2019s questions, so one may be waiting here.') +
1046
+ ' <button type="button" class="link-btn" data-ta="q-retry">Retry</button></p>';
1013
1047
  if (where === 'answered') html += '<p class="ta-sub ta-mac-line">' + esc((W.sessionQuestions && W.sessionQuestions.answeredLine) || 'Answered. The session carries on.') + '</p>';
1014
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();
1015
1050
  } else if (O.row || O.r) {
1016
1051
  // Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
1017
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>';
@@ -1026,18 +1061,78 @@
1026
1061
  box.innerHTML = html;
1027
1062
  // The reply box shows only where a reply can be the answer; what was typed stays in it, hidden.
1028
1063
  var foot = $('ta-foot');
1029
- if (foot && O.open.kind === 'wait') foot.hidden = waitWhere() !== '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'; }
1030
1065
  }
1031
1066
  // Where a waiting session's question is answered: 'here' (its cards in this sheet), 'answered' (on some screen just
1032
1067
  // now), 'mac' (a Claude session's own prompt, or a held question that went back to the Mac), or 'reply' (a Codex or
1033
1068
  // Cursor session asking in words).
1069
+ // W13: 'failed' when the last questions read failed and no card is drawn: a Claude reply still answers nothing, so the
1070
+ // reply box stays a Codex or Cursor one.
1034
1071
  function waitWhere() {
1035
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';
1036
1075
  if (O.r && W.sessionQuestions && W.sessionQuestions.answeredFor && W.sessionQuestions.answeredFor(O.r.id)) return 'answered';
1076
+ if (!O.macCount && W.sessionQuestions && W.sessionQuestions.loadState && W.sessionQuestions.loadState() === 'failed') return 'failed';
1037
1077
  if (O.macCount || (O.r && O.r.provider === 'claude') || (O.wait && O.wait.where === 'mac')) return 'mac';
1038
1078
  return 'reply';
1039
1079
  }
1040
- function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens)."; }
1080
+ // The question in Today's own words (its Needs you row), unless the line above the sheet already reads exactly that.
1081
+ function waitAsk() {
1082
+ var a = O.wait && O.wait.ask ? String(O.wait.ask).trim() : '';
1083
+ return a && !(O.wait.why && String(O.wait.why).trim() === a) ? a : '';
1084
+ }
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))); }
1041
1136
 
1042
1137
  // ═════════════ The pet's sessions ═════════════
1043
1138
  // The same sessions the pet's caption counts (app.js petPlay): waiting on you, running, then done today. Each row
@@ -1098,7 +1193,7 @@
1098
1193
  function loadLast(r) {
1099
1194
  var request = r.lastRead = {};
1100
1195
  O.lastState = 'loading'; O.lastWhy = ''; paintLast();
1101
- return K.answer(K.path(r.provider, r.id, '?turns=6'), { timeoutMs: 20000 }).then(function (a) {
1196
+ return K.answer(K.path(r.provider, r.id, '?turns=6&cards=1'), { timeoutMs: 20000 }).then(function (a) {
1102
1197
  if (O.r !== r || r.lastRead !== request) return;
1103
1198
  if (a.status === 404) { O.lastState = 'none'; O.lastWhy = 'This session is not on your Mac any more.'; paintLast(); return; }
1104
1199
  if (a.status !== 200) { O.lastState = 'error'; paintLast(); return; }
@@ -1106,7 +1201,8 @@
1106
1201
  if (!O.row) { O.row = d; paintMeta(); if (O.open && O.open.kind === 'run') setHead(K.title(d)); }
1107
1202
  var turns = Array.isArray(d.recent_turns) ? d.recent_turns : [];
1108
1203
  var lastA = turns.filter(function (t) { return lower(t.role) !== 'user' && (String(t.text || t.content || '').trim() || (K.imageHtml && K.imageHtml(t))); }).pop();
1109
- O.last = lastA ? { text: String(lastA.text || lastA.content || '').trim(), at: lastA.at, outputImages: lastA.outputImages } : null;
1204
+ O.last = lastA ? { text: String(lastA.text || lastA.content || '').trim(), at: lastA.at, toolImages: lastA.toolImages, toolImagesMore: lastA.toolImagesMore, outputLinks: lastA.outputLinks,
1205
+ outputImages: lastA.outputImages, outputFiles: lastA.outputFiles, previewsOff: lastA.previewsOff } : null;
1110
1206
  O.lastState = O.last ? 'ready' : 'none';
1111
1207
  O.lastWhy = '';
1112
1208
  paintLast();
@@ -1116,7 +1212,7 @@
1116
1212
  var box = $('ta-last'); if (!box) return;
1117
1213
  // Retain the actual reply nodes (and its open image) during refreshes and transient errors.
1118
1214
  // Only a successful changed reply replaces them. Metadata changes count as changed too.
1119
- var signature = O.last ? JSON.stringify([O.last.text, O.last.at, K.imageHtml ? K.imageHtml(O.last) : '']) : '';
1215
+ var signature = O.last ? JSON.stringify([O.last.text, O.last.at, K.imageHtml ? K.imageHtml(O.last) : '', K.linksAttr ? K.linksAttr(O.last) : '']) : '';
1120
1216
  if (signature && box.__taLastSignature === signature) {
1121
1217
  var refresh = $('ta-last-refresh');
1122
1218
  if (refresh) refresh.textContent = O.lastState === 'loading' ? 'Checking for a newer reply…'
@@ -1132,8 +1228,10 @@
1132
1228
  if (!O.last) { box.innerHTML = ''; return; }
1133
1229
  var at = W.toDate(O.last.at), long = O.last.text.length > 700;
1134
1230
  box.innerHTML = '<section class="ta-last" aria-label="Last reply"><p class="ta-last-head">' + esc('Last reply' + (at ? ', ' + W.clock(at) : '')) + '</p>' +
1135
- '<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text">' + K.markdown(O.last.text.slice(0, 12000)) + '</div>' +
1136
- (long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + (K.imageHtml ? K.imageHtml(O.last) : '') + '<p id="ta-last-refresh" class="ta-sub" role="status"></p></section>';
1231
+ (K.imageHtml ? K.imageHtml(O.last, { part: 'before' }) : '') +
1232
+ // Preview engine phase 1: the reply's link cards, as in the reader.
1233
+ '<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text"' + (K.linksAttr ? K.linksAttr(O.last) : '') + '>' + K.markdown(O.last.text.slice(0, 12000), O.last.outputLinks) + '</div>' +
1234
+ (long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + (K.imageHtml ? K.imageHtml(O.last, { part: 'after' }) : '') + '<p id="ta-last-refresh" class="ta-sub" role="status"></p></section>';
1137
1235
  if (K.imagesMount) K.imagesMount(box, O.r);
1138
1236
  }
1139
1237
 
@@ -1234,7 +1332,8 @@
1234
1332
  var fb = sent.length ? filesBox(r) : null, kit = KIT();
1235
1333
  if (fb && kit) fb.atts.filter(function (t) { return sent.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(fb, t.id); });
1236
1334
  var box = r === O.r ? $('ta-input') : null;
1237
- var left = box ? (box.value.trim() === String(text).trim() ? '' : box.value) : '';
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) : '';
1238
1337
  if (box && !left) { box.value = ''; W.autoGrow(box); }
1239
1338
  keepDraft(r, left, null);
1240
1339
  },
@@ -1275,7 +1374,8 @@
1275
1374
  '</div></div>';
1276
1375
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
1277
1376
  // 6.99.29: "Working right now" only while the card's one state says so.
1278
- if (K.feed.parkNoteShows(r)) html = '<p class="sx-park">' + esc(K.parkCopy(r.provider)) + '</p>';
1377
+ // QA 7.0.0: files in this sheet's box wait for the turn end, so the note says so (app.js sxParkLine).
1378
+ if (K.feed.parkNoteShows(r)) html = '<p class="sx-park">' + esc(K.parkCopy(r.provider, r, !!(filesOn() && filesState(filesBox(r)) !== 'none'))) + '</p>';
1279
1379
  } else if (v && v.attachable === false && v.reasonCopy) {
1280
1380
  html = '<div class="sx-box refusal" role="status"><p>' + esc(v.reasonCopy) + '</p></div>';
1281
1381
  } else if (v && v.caution) {
@@ -1309,6 +1409,8 @@
1309
1409
  if (r.refusal || O.cautionAsk) { r.refusal = null; O.cautionAsk = false; paintVerdict(); }
1310
1410
  r.sending = true; setSending(true);
1311
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);
1312
1414
  var sending = K.send(r, text, opts || {});
1313
1415
  // continueSession picks the key at once (draftFor): keep it until the Mac takes the words.
1314
1416
  if (r.draft) keepDraft(r, text, r.draft);
@@ -313,3 +313,16 @@
313
313
  .wk-pick-row { display: flex; align-items: center; gap: 10px; padding: 8px 2px; border-top: 1px solid var(--hairline); }
314
314
  .wk-pick-row > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
315
315
  .wk-pick-row strong { font-size: 13.5px; }
316
+
317
+ /* Duo tables: the divider between a card and its side panel (app.js splitKit, styled in app.css; work.js DIVIDER). From
318
+ 900 px only, where both show. Its 22 px column is the old gap, so the default layout is unchanged; a dragged width
319
+ sizes the side panel, clamped so it keeps 320 px and the card 380 px. */
320
+ @media (min-width: 900px) {
321
+ .wk-detail.split-side { grid-template-columns: minmax(0, 1fr) 22px minmax(320px, 400px); column-gap: 0; }
322
+ .wk-detail.split-side.split-sized { grid-template-columns: minmax(0, 1fr) 22px clamp(320px, var(--split-side-share, 30%), calc(100% - 402px)); }
323
+ .wk-detail.split-side > .split-handle { display: flex; }
324
+ }
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; }